From 75767f61a7e2e3c731b65f75417ce1a38d065369 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 01/20] feat(web-domain): add Mobile API domain schemas --- packages/web-domain/src/Mobile.ts | 435 ++++++++++++++++++++++++++++++ packages/web-domain/src/index.ts | 1 + 2 files changed, 436 insertions(+) create mode 100644 packages/web-domain/src/Mobile.ts diff --git a/packages/web-domain/src/Mobile.ts b/packages/web-domain/src/Mobile.ts new file mode 100644 index 0000000000..3dfafc652c --- /dev/null +++ b/packages/web-domain/src/Mobile.ts @@ -0,0 +1,435 @@ +import { + HttpApi, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + OpenApi, +} from "@effect/platform"; +import { Schema } from "effect"; +import { HttpAuthMiddleware } from "./Authentication.ts"; +import { CommentId } from "./Comment.ts"; +import { FolderColor, FolderId } from "./Folder.ts"; +import { OrganisationId } from "./Organisation.ts"; +import { UploadTarget } from "./Storage.ts"; +import { UserId } from "./User.ts"; +import { UploadPhase, VideoId } from "./Video.ts"; + +export const MobileApiKeyResponse = Schema.Struct({ + type: Schema.Literal("api_key"), + apiKey: Schema.String, + userId: UserId, +}); + +export const MobileSuccessResponse = Schema.Struct({ + success: Schema.Literal(true), +}); + +export const MobileAuthConfigResponse = Schema.Struct({ + googleAuthAvailable: Schema.Boolean, + workosAuthAvailable: Schema.Boolean, +}); + +export const MobileSessionRequestParams = Schema.Struct({ + redirectUri: Schema.optional(Schema.String), + provider: Schema.optional(Schema.Literal("google", "workos")), + organizationId: Schema.optional(Schema.String), +}); + +export const MobileEmailSessionRequestInput = Schema.Struct({ + email: Schema.String, +}); + +export const MobileEmailSessionVerifyInput = Schema.Struct({ + email: Schema.String, + code: Schema.String, +}); + +export const MobileAuthHeaders = Schema.Struct({ + authorization: Schema.optional(Schema.String), +}); + +export const MobileUser = Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + email: Schema.String, + imageUrl: Schema.NullOr(Schema.String), + activeOrganizationId: OrganisationId, +}); + +export const MobileOrganization = Schema.Struct({ + id: OrganisationId, + name: Schema.String, + iconUrl: Schema.NullOr(Schema.String), + role: Schema.Literal("owner", "admin", "member"), +}); + +export const MobileFolder = Schema.Struct({ + id: FolderId, + name: Schema.String, + color: FolderColor, + parentId: Schema.NullOr(FolderId), + videoCount: Schema.Number, +}); + +export const MobileUploadProgress = Schema.Struct({ + uploaded: Schema.Number, + total: Schema.Number, + phase: UploadPhase, + processingProgress: Schema.Number, + processingMessage: Schema.NullOr(Schema.String), + processingError: Schema.NullOr(Schema.String), +}); + +export const MobileCapSummary = Schema.Struct({ + id: VideoId, + shareUrl: Schema.String, + title: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + ownerName: Schema.String, + durationSeconds: Schema.NullOr(Schema.Number), + thumbnailUrl: Schema.NullOr(Schema.String), + folderId: Schema.NullOr(FolderId), + public: Schema.Boolean, + protected: Schema.Boolean, + viewCount: Schema.Number, + commentCount: Schema.Number, + reactionCount: Schema.Number, + upload: Schema.NullOr(MobileUploadProgress), +}); + +export const MobileComment = Schema.Struct({ + id: CommentId, + videoId: VideoId, + type: Schema.Literal("text", "emoji"), + content: Schema.String, + timestamp: Schema.NullOr(Schema.Number), + parentCommentId: Schema.NullOr(CommentId), + createdAt: Schema.String, + updatedAt: Schema.String, + author: Schema.Struct({ + id: UserId, + name: Schema.NullOr(Schema.String), + imageUrl: Schema.NullOr(Schema.String), + }), +}); + +export const MobileChapter = Schema.Struct({ + title: Schema.String, + start: Schema.Number, +}); + +export const MobileCapDetail = Schema.Struct({ + cap: MobileCapSummary, + summary: Schema.NullOr(Schema.String), + chapters: Schema.Array(MobileChapter), + transcriptionStatus: Schema.NullOr( + Schema.Literal("PROCESSING", "COMPLETE", "ERROR", "SKIPPED", "NO_AUDIO"), + ), + comments: Schema.Array(MobileComment), + shareUrl: Schema.String, +}); + +export const MobileCapsListParams = Schema.Struct({ + folderId: Schema.optional(Schema.String), + page: Schema.optional(Schema.String), + limit: Schema.optional(Schema.String), +}); + +export const MobileCapsListResponse = Schema.Struct({ + folders: Schema.Array(MobileFolder), + caps: Schema.Array(MobileCapSummary), + page: Schema.Number, + limit: Schema.Number, + total: Schema.Number, + hasMore: Schema.Boolean, +}); + +export const MobileBootstrapResponse = Schema.Struct({ + user: MobileUser, + organizations: Schema.Array(MobileOrganization), + activeOrganizationId: Schema.NullOr(OrganisationId), + rootFolders: Schema.Array(MobileFolder), +}); + +export const MobileActiveOrganizationInput = Schema.Struct({ + organizationId: OrganisationId, +}); + +export const MobileCapSharingInput = Schema.Struct({ + public: Schema.Boolean, +}); + +export const MobileCapTitleInput = Schema.Struct({ + title: Schema.String, +}); + +export const MobileCapPasswordInput = Schema.Struct({ + password: Schema.NullOr(Schema.String), +}); + +export const MobileFolderCreateInput = Schema.Struct({ + name: Schema.String, + color: Schema.optional(FolderColor), +}); + +export const MobileVideoPath = Schema.Struct({ + id: VideoId, +}); + +export const MobileCommentPath = Schema.Struct({ + id: CommentId, +}); + +export const MobileUploadPath = Schema.Struct({ + id: VideoId, +}); + +export const MobileCommentCreateInput = Schema.Struct({ + content: Schema.String, + timestamp: Schema.NullOr(Schema.Number), + parentCommentId: Schema.optional(Schema.NullOr(CommentId)), +}); + +export const MobileReactionCreateInput = Schema.Struct({ + content: Schema.String, + timestamp: Schema.NullOr(Schema.Number), +}); + +export const MobilePlaybackResponse = Schema.Struct({ + kind: Schema.Literal("mp4", "hls"), + url: Schema.String, + transcriptUrl: Schema.NullOr(Schema.String), +}); + +export const MobileDownloadResponse = Schema.Struct({ + fileName: Schema.String, + url: Schema.String, +}); + +export const MobileUploadCreateInput = Schema.Struct({ + organizationId: Schema.optional(OrganisationId), + folderId: Schema.optional(FolderId), + fileName: Schema.String, + contentType: Schema.String, + contentLength: Schema.optional(Schema.Number), + durationSeconds: Schema.optional(Schema.Number), + width: Schema.optional(Schema.Number), + height: Schema.optional(Schema.Number), + fps: Schema.optional(Schema.Number), +}); + +export const MobileUploadCreateResponse = Schema.Struct({ + id: VideoId, + shareUrl: Schema.String, + rawFileKey: Schema.String, + upload: UploadTarget, + cap: MobileCapSummary, +}); + +export const MobileUploadProgressInput = Schema.Struct({ + uploaded: Schema.Number, + total: Schema.Number, +}); + +export const MobileUploadCompleteInput = Schema.Struct({ + rawFileKey: Schema.String, + contentLength: Schema.optional(Schema.Number), +}); + +export class MobileHttpApi extends HttpApiGroup.make("mobile") + .add( + HttpApiEndpoint.get("getAuthConfig", "/session/config").addSuccess( + MobileAuthConfigResponse, + ), + ) + .add( + HttpApiEndpoint.get("requestSession", "/session/request") + .setUrlParams(MobileSessionRequestParams) + .addSuccess(MobileApiKeyResponse) + .addError(HttpApiError.InternalServerError) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("requestEmailSession", "/session/email/request") + .setPayload(MobileEmailSessionRequestInput) + .addSuccess(MobileSuccessResponse) + .addError(HttpApiError.InternalServerError) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("verifyEmailSession", "/session/email/verify") + .setPayload(MobileEmailSessionVerifyInput) + .addSuccess(MobileApiKeyResponse) + .addError(HttpApiError.InternalServerError) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("revokeSession", "/session/revoke") + .setHeaders(MobileAuthHeaders) + .addSuccess(MobileSuccessResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.get("bootstrap", "/bootstrap") + .addSuccess(MobileBootstrapResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.patch("setActiveOrganization", "/user/active-organization") + .setPayload(MobileActiveOrganizationInput) + .addSuccess(MobileBootstrapResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.get("listCaps", "/caps") + .setUrlParams(MobileCapsListParams) + .addSuccess(MobileCapsListResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("createFolder", "/folders") + .setPayload(MobileFolderCreateInput) + .addSuccess(MobileFolder) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.get("getCap", "/caps/:id") + .setPath(MobileVideoPath) + .addSuccess(MobileCapDetail) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.patch("updateCapSharing", "/caps/:id/sharing") + .setPath(MobileVideoPath) + .setPayload(MobileCapSharingInput) + .addSuccess(MobileCapSummary) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.patch("updateCapTitle", "/caps/:id/title") + .setPath(MobileVideoPath) + .setPayload(MobileCapTitleInput) + .addSuccess(MobileCapSummary) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.patch("updateCapPassword", "/caps/:id/password") + .setPath(MobileVideoPath) + .setPayload(MobileCapPasswordInput) + .addSuccess(MobileCapSummary) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.del("deleteCap", "/caps/:id") + .setPath(MobileVideoPath) + .addSuccess(MobileSuccessResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.get("getPlayback", "/caps/:id/playback") + .setPath(MobileVideoPath) + .addSuccess(MobilePlaybackResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.get("getDownload", "/caps/:id/download") + .setPath(MobileVideoPath) + .addSuccess(MobileDownloadResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("createComment", "/caps/:id/comments") + .setPath(MobileVideoPath) + .setPayload(MobileCommentCreateInput) + .addSuccess(MobileComment) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.del("deleteComment", "/comments/:id") + .setPath(MobileCommentPath) + .addSuccess(MobileSuccessResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("createReaction", "/caps/:id/reactions") + .setPath(MobileVideoPath) + .setPayload(MobileReactionCreateInput) + .addSuccess(MobileComment) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("createUpload", "/uploads") + .setPayload(MobileUploadCreateInput) + .addSuccess(MobileUploadCreateResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("updateUploadProgress", "/uploads/:id/progress") + .setPath(MobileUploadPath) + .setPayload(MobileUploadProgressInput) + .addSuccess(MobileSuccessResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) + .add( + HttpApiEndpoint.post("completeUpload", "/uploads/:id/complete") + .setPath(MobileUploadPath) + .setPayload(MobileUploadCompleteInput) + .addSuccess(MobileSuccessResponse) + .middleware(HttpAuthMiddleware) + .addError(HttpApiError.Forbidden) + .addError(HttpApiError.NotFound), + ) {} + +export class MobileApiContract extends HttpApi.make("cap-mobile-api") + .add(MobileHttpApi) + .annotateContext( + OpenApi.annotations({ + title: "Cap Mobile API", + description: "Authenticated API used by the Cap iOS app", + }), + ) + .prefix("/api/mobile") {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index b8aca7049a..dc3db577dd 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -9,6 +9,7 @@ export * as ImageUpload from "./ImageUpload.ts"; export * as Language from "./Language.ts"; export * from "./Language.ts"; export * as Loom from "./Loom.ts"; +export * as Mobile from "./Mobile.ts"; export * as Organisation from "./Organisation.ts"; export * from "./Organisation.ts"; export * as Policy from "./Policy.ts"; From 4b46c653fac88f800c226d5a609e848f61abaeac Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 02/20] chore(database): export auth domain-utils package entry --- packages/database/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/database/package.json b/packages/database/package.json index a221a469a3..aa4bc320f8 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -58,6 +58,7 @@ "exports": { ".": "./index.ts", "./auth/auth-options": "./auth/auth-options.ts", + "./auth/domain-utils": "./auth/domain-utils.ts", "./auth/session": "./auth/session.ts", "./schema": "./schema.ts", "./crypto": "./crypto.ts", @@ -71,6 +72,7 @@ "exports": { ".": "./dist/index.js", "./auth/auth-options": "./dist/auth/auth-options.js", + "./auth/domain-utils": "./dist/auth/domain-utils.js", "./auth/session": "./dist/auth/session.js", "./schema": "./dist/schema.js", "./crypto": "./dist/crypto.js", From 6c2d63d07f7cf22c32d3a5179c626105e9ea060e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 03/20] feat(web): add mobile HTTP API route --- apps/web/app/api/mobile/[...route]/route.ts | 1409 +++++++++++++++++++ 1 file changed, 1409 insertions(+) create mode 100644 apps/web/app/api/mobile/[...route]/route.ts diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts new file mode 100644 index 0000000000..af24edec13 --- /dev/null +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -0,0 +1,1409 @@ +import crypto from "node:crypto"; +import { authOptions } from "@cap/database/auth/auth-options"; +import { isEmailAllowedForSignup } from "@cap/database/auth/domain-utils"; +import { hashPassword } from "@cap/database/crypto"; +import { sendEmail } from "@cap/database/emails/config"; +import { OTPEmail } from "@cap/database/emails/otp-email"; +import { nanoId } from "@cap/database/helpers"; +import * as Db from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { + Database, + getCurrentUser, + ImageUploads, + Storage, + Videos, + VideosRepo, +} from "@cap/web-backend"; +import { + Comment, + CurrentUser, + Folder, + Mobile, + type Organisation, + User, + Video, +} from "@cap/web-domain"; +import { + HttpApiBuilder, + HttpApiError, + HttpServerResponse, +} from "@effect/platform"; +import { and, count, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { Effect, Exit, Layer, Option } from "effect"; +import { revalidatePath } from "next/cache"; +import { createNotification } from "@/lib/Notification"; +import { apiToHandler } from "@/lib/server"; +import { startVideoProcessingWorkflow } from "@/lib/video-processing"; + +export const dynamic = "force-dynamic"; + +type CapRow = { + id: Video.VideoId; + name: string; + createdAt: Date; + updatedAt: Date; + ownerName: string | null; + duration: number | null; + folderId: Folder.FolderId | null; + public: boolean; + hasPassword: boolean; + commentCount: number; + reactionCount: number; + uploadVideoId: Video.VideoId | null; + uploadUploaded: number | null; + uploadTotal: number | null; + uploadPhase: Video.UploadPhase | null; + processingProgress: number | null; + processingMessage: string | null; + processingError: string | null; + metadata: unknown; + transcriptionStatus: + | "PROCESSING" + | "COMPLETE" + | "ERROR" + | "SKIPPED" + | "NO_AUDIO" + | null; +}; + +type MobileCapSummary = (typeof Mobile.MobileCapSummary)["Type"]; +type MobileFolder = (typeof Mobile.MobileFolder)["Type"]; +type MobileOrganization = (typeof Mobile.MobileOrganization)["Type"]; +type MobileFolderCreateInput = (typeof Mobile.MobileFolderCreateInput)["Type"]; +type MobileUploadCreateInput = (typeof Mobile.MobileUploadCreateInput)["Type"]; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const emailCodePattern = /^\d{6}$/; +const emailCodeTtlMs = 10 * 60 * 1000; + +const toIsoString = (value: Date) => value.toISOString(); + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +const hashEmailCode = (code: string) => + crypto + .createHash("sha256") + .update(`${code}${serverEnv().NEXTAUTH_SECRET}`) + .digest("hex"); + +const sendMobileEmailCode = async (email: string, code: string) => { + if (!serverEnv().RESEND_API_KEY) { + console.log(""); + console.log("Cap mobile verification code"); + console.log(`Email: ${email}`); + console.log(`Code: ${code}`); + console.log("Expires in: 10 minutes"); + console.log(""); + return; + } + + await sendEmail({ + email, + subject: "Your Cap Verification Code", + react: OTPEmail({ code, email }), + }); +}; + +const getEmailAuthAdapter = () => { + const adapter = authOptions().adapter; + const { createUser, getUserByEmail, updateUser } = adapter ?? {}; + + if (!createUser || !getUserByEmail || !updateUser) { + throw new Error("Email auth adapter is not configured"); + } + + return { createUser, getUserByEmail, updateUser }; +}; + +const createOrUpdateEmailUser = async (email: string) => { + const { createUser, getUserByEmail, updateUser } = getEmailAuthAdapter(); + const existingUser = await getUserByEmail(email); + + if (existingUser) { + return updateUser({ + id: existingUser.id, + emailVerified: new Date(), + }); + } + + return createUser({ + email, + emailVerified: new Date(), + image: null, + name: null, + }); +}; + +const parseBearerToken = (authorization: string | undefined) => { + if (!authorization) return null; + const [scheme, token] = authorization.split(" "); + if (scheme?.toLowerCase() !== "bearer" || !token) return null; + return token; +}; + +const parsePositiveInteger = ( + value: string | undefined, + fallback: number, + max: number, +) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.min(Math.trunc(parsed), max); +}; + +const getMetadataRecord = (metadata: unknown): Record => { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return {}; + } + return metadata as Record; +}; + +const getMetadataString = (metadata: Record, key: string) => { + const value = metadata[key]; + return typeof value === "string" && value.length > 0 ? value : null; +}; + +const getMetadataChapters = (metadata: Record) => { + const chapters = metadata.chapters; + if (!Array.isArray(chapters)) return []; + + return chapters.flatMap((chapter) => { + if (!chapter || typeof chapter !== "object" || Array.isArray(chapter)) { + return []; + } + const value = chapter as Record; + const title = value.title; + const start = value.start; + if (typeof title !== "string" || typeof start !== "number") return []; + return [{ title, start }]; + }); +}; + +const getDeploymentOrigin = () => { + const webUrl = serverEnv().WEB_URL; + const vercelEnv = serverEnv().VERCEL_ENV; + + if (!vercelEnv || vercelEnv === "production") return webUrl; + + if (vercelEnv === "preview") { + const branchHost = serverEnv().VERCEL_BRANCH_URL_HOST; + if (branchHost?.endsWith(".vercel.app")) return `https://${branchHost}`; + } + + return webUrl; +}; + +const getFileExtension = (input: MobileUploadCreateInput) => { + const fileNameExtension = input.fileName.split(".").at(-1)?.toLowerCase(); + if ( + fileNameExtension && + fileNameExtension !== input.fileName.toLowerCase() && + /^[a-z0-9]+$/.test(fileNameExtension) + ) { + return fileNameExtension; + } + + if (input.contentType.includes("quicktime")) return "mov"; + if (input.contentType.includes("webm")) return "webm"; + if (input.contentType.includes("matroska")) return "mkv"; + if (input.contentType.includes("x-msvideo")) return "avi"; + if (input.contentType.includes("x-m4v")) return "m4v"; + return "mp4"; +}; + +const getUploadTitle = (fileName: string) => { + const title = fileName.replace(/\.[^/.]+$/, "").trim(); + return title.length > 0 ? title : "Mobile Upload"; +}; + +const toMobileCapSummary = ( + row: CapRow, + thumbnailUrl: string | null, + viewCount: number, +): MobileCapSummary => ({ + id: row.id, + shareUrl: `${serverEnv().WEB_URL}/s/${row.id}`, + title: row.name, + createdAt: toIsoString(row.createdAt), + updatedAt: toIsoString(row.updatedAt), + ownerName: row.ownerName ?? "", + durationSeconds: row.duration, + thumbnailUrl, + folderId: row.folderId, + public: row.public, + protected: row.hasPassword, + viewCount, + commentCount: Number(row.commentCount), + reactionCount: Number(row.reactionCount), + upload: row.uploadVideoId + ? { + uploaded: Number(row.uploadUploaded ?? 0), + total: Number(row.uploadTotal ?? 0), + phase: row.uploadPhase ?? "uploading", + processingProgress: Number(row.processingProgress ?? 0), + processingMessage: row.processingMessage, + processingError: row.processingError, + } + : null, +}); + +const withMappedErrors = (effect: Effect.Effect) => + effect.pipe( + Effect.catchTags({ + DatabaseError: () => new HttpApiError.InternalServerError(), + NoSuchElementException: () => new HttpApiError.NotFound(), + PolicyDenied: () => new HttpApiError.Forbidden(), + S3Error: () => new HttpApiError.InternalServerError(), + StorageError: () => new HttpApiError.InternalServerError(), + UnknownException: () => new HttpApiError.InternalServerError(), + VerifyVideoPasswordError: () => new HttpApiError.Forbidden(), + VideoNotFoundError: () => new HttpApiError.NotFound(), + }), + ); + +const ensureEmailSignInAllowed = Effect.fn("Mobile.ensureEmailSignInAllowed")( + function* (email: string) { + if (!emailPattern.test(email)) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const allowedDomains = serverEnv().CAP_ALLOWED_SIGNUP_DOMAINS; + if (!allowedDomains) return; + + const database = yield* Database; + const [existingUser] = yield* database.use((db) => + db + .select({ id: Db.users.id }) + .from(Db.users) + .where(eq(Db.users.email, email)) + .limit(1), + ); + + if (!existingUser && !isEmailAllowedForSignup(email, allowedDomains)) { + return yield* Effect.fail(new HttpApiError.Forbidden()); + } + }, +); + +const createMobileApiKey = Effect.fn("Mobile.createMobileApiKey")(function* ( + userId: User.UserId, +) { + const database = yield* Database; + const apiKey = crypto.randomUUID(); + yield* database.use((db) => + db.insert(Db.authApiKeys).values({ + id: apiKey, + userId, + }), + ); + + return { + type: "api_key" as const, + apiKey, + userId, + }; +}); + +const requestEmailSession = Effect.fn("Mobile.requestEmailSession")(function* ( + rawEmail: string, +) { + const email = normalizeEmail(rawEmail); + yield* ensureEmailSignInAllowed(email); + + const code = crypto.randomInt(100000, 1000000).toString(); + const token = hashEmailCode(code); + const expires = new Date(Date.now() + emailCodeTtlMs); + const database = yield* Database; + + yield* database.use(async (db) => { + const [existingToken] = await db + .select({ identifier: Db.verificationTokens.identifier }) + .from(Db.verificationTokens) + .where(eq(Db.verificationTokens.identifier, email)) + .limit(1); + + if (existingToken) { + await db + .update(Db.verificationTokens) + .set({ token, expires }) + .where(eq(Db.verificationTokens.identifier, email)); + return; + } + + await db.insert(Db.verificationTokens).values({ + identifier: email, + token, + expires, + }); + }); + + yield* Effect.tryPromise({ + try: () => sendMobileEmailCode(email, code), + catch: () => new HttpApiError.InternalServerError(), + }); + + return { success: true as const }; +}); + +const verifyEmailSession = Effect.fn("Mobile.verifyEmailSession")(function* ({ + email: rawEmail, + code: rawCode, +}: (typeof Mobile.MobileEmailSessionVerifyInput)["Type"]) { + const email = normalizeEmail(rawEmail); + const code = rawCode.trim(); + + if (!emailPattern.test(email) || !emailCodePattern.test(code)) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + yield* ensureEmailSignInAllowed(email); + + const database = yield* Database; + const token = hashEmailCode(code); + const [verificationToken] = yield* database.use((db) => + db + .select() + .from(Db.verificationTokens) + .where( + and( + eq(Db.verificationTokens.identifier, email), + eq(Db.verificationTokens.token, token), + ), + ) + .limit(1), + ); + + if (!verificationToken) { + return yield* Effect.fail(new HttpApiError.Forbidden()); + } + + yield* database.use((db) => + db + .delete(Db.verificationTokens) + .where( + and( + eq(Db.verificationTokens.identifier, email), + eq(Db.verificationTokens.token, token), + ), + ), + ); + + if (verificationToken.expires.valueOf() < Date.now()) { + return yield* Effect.fail(new HttpApiError.Forbidden()); + } + + const user = yield* Effect.tryPromise({ + try: () => createOrUpdateEmailUser(email), + catch: () => new HttpApiError.InternalServerError(), + }); + + return yield* createMobileApiKey(User.UserId.make(user.id)); +}); + +const getAccessibleOrganizations = Effect.fn( + "Mobile.getAccessibleOrganizations", +)(function* (userId: User.UserId) { + const database = yield* Database; + const imageUploads = yield* ImageUploads; + + const rows = yield* database.use((db) => + db + .select({ + id: Db.organizations.id, + name: Db.organizations.name, + ownerId: Db.organizations.ownerId, + iconUrl: Db.organizations.iconUrl, + role: Db.organizationMembers.role, + }) + .from(Db.organizations) + .leftJoin( + Db.organizationMembers, + and( + eq(Db.organizationMembers.organizationId, Db.organizations.id), + eq(Db.organizationMembers.userId, userId), + ), + ) + .where( + and( + isNull(Db.organizations.tombstoneAt), + or( + eq(Db.organizations.ownerId, userId), + eq(Db.organizationMembers.userId, userId), + ), + ), + ), + ); + + return yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const role: MobileOrganization["role"] = + row.ownerId === userId ? "owner" : (row.role ?? "member"); + const iconUrl = row.iconUrl + ? yield* imageUploads.resolveImageUrl(row.iconUrl) + : null; + + return { + id: row.id, + name: row.name, + iconUrl, + role, + }; + }), + { concurrency: 5 }, + ); +}); + +const getRootFolders = Effect.fn("Mobile.getRootFolders")(function* ( + organizationId: Organisation.OrganisationId, +) { + const user = yield* CurrentUser; + const database = yield* Database; + + const rows = yield* database.use((db) => + db + .select({ + id: Db.folders.id, + name: Db.folders.name, + color: Db.folders.color, + parentId: Db.folders.parentId, + videoCount: sql`( + SELECT COUNT(*) + FROM ${Db.videos} + WHERE ${Db.videos.folderId} = ${Db.folders.id} + AND ${Db.videos.ownerId} = ${user.id} + AND ${Db.videos.orgId} = ${organizationId} + )`, + }) + .from(Db.folders) + .where( + and( + eq(Db.folders.organizationId, organizationId), + eq(Db.folders.createdById, user.id), + isNull(Db.folders.parentId), + isNull(Db.folders.spaceId), + ), + ), + ); + + return rows satisfies MobileFolder[]; +}); + +const assertOrganizationAccess = Effect.fn("Mobile.assertOrganizationAccess")( + function* (organizationId: Organisation.OrganisationId) { + const user = yield* CurrentUser; + const organizations = yield* getAccessibleOrganizations(user.id); + const hasAccess = organizations.some((org) => org.id === organizationId); + if (!hasAccess) return yield* Effect.fail(new HttpApiError.Forbidden()); + }, +); + +const getBootstrap = Effect.fn("Mobile.getBootstrap")(function* () { + const user = yield* CurrentUser; + const database = yield* Database; + const imageUploads = yield* ImageUploads; + + const [userRow] = yield* database.use((db) => + db + .select({ + id: Db.users.id, + name: Db.users.name, + email: Db.users.email, + image: Db.users.image, + activeOrganizationId: Db.users.activeOrganizationId, + }) + .from(Db.users) + .where(eq(Db.users.id, user.id)), + ); + if (!userRow) return yield* Effect.fail(new HttpApiError.Unauthorized()); + + const organizations = yield* getAccessibleOrganizations(user.id); + const activeOrganization = + organizations.find((org) => org.id === userRow.activeOrganizationId) ?? + organizations[0] ?? + null; + const activeOrganizationId = activeOrganization?.id ?? null; + const rootFolders = activeOrganizationId + ? yield* getRootFolders(activeOrganizationId) + : []; + const imageUrl = userRow.image + ? yield* imageUploads.resolveImageUrl(userRow.image) + : null; + + return { + user: { + id: userRow.id, + name: userRow.name, + email: userRow.email, + imageUrl, + activeOrganizationId: activeOrganizationId ?? user.activeOrganizationId, + }, + organizations, + activeOrganizationId, + rootFolders, + }; +}); + +const getCapRows = Effect.fn("Mobile.getCapRows")(function* ({ + folderId, + page, + limit, +}: { + folderId: Folder.FolderId | null; + page: number; + limit: number; +}) { + const user = yield* CurrentUser; + const database = yield* Database; + const offset = (page - 1) * limit; + const folderFilter = folderId + ? eq(Db.videos.folderId, folderId) + : isNull(Db.videos.folderId); + const whereClause = and( + eq(Db.videos.ownerId, user.id), + eq(Db.videos.orgId, user.activeOrganizationId), + folderFilter, + isNull(Db.organizations.tombstoneAt), + ); + + const [totalRow] = yield* database.use((db) => + db + .select({ value: count() }) + .from(Db.videos) + .leftJoin(Db.organizations, eq(Db.videos.orgId, Db.organizations.id)) + .where(whereClause), + ); + + const rows = yield* database.use((db) => + db + .select({ + id: Db.videos.id, + name: Db.videos.name, + createdAt: Db.videos.createdAt, + updatedAt: Db.videos.updatedAt, + ownerName: Db.users.name, + duration: Db.videos.duration, + folderId: Db.videos.folderId, + public: Db.videos.public, + hasPassword: sql`${Db.videos.password} IS NOT NULL`.mapWith( + Boolean, + ), + commentCount: sql`COUNT(DISTINCT CASE WHEN ${Db.comments.type} = 'text' THEN ${Db.comments.id} END)`, + reactionCount: sql`COUNT(DISTINCT CASE WHEN ${Db.comments.type} = 'emoji' THEN ${Db.comments.id} END)`, + uploadVideoId: Db.videoUploads.videoId, + uploadUploaded: Db.videoUploads.uploaded, + uploadTotal: Db.videoUploads.total, + uploadPhase: Db.videoUploads.phase, + processingProgress: Db.videoUploads.processingProgress, + processingMessage: Db.videoUploads.processingMessage, + processingError: Db.videoUploads.processingError, + metadata: Db.videos.metadata, + transcriptionStatus: Db.videos.transcriptionStatus, + }) + .from(Db.videos) + .leftJoin(Db.comments, eq(Db.videos.id, Db.comments.videoId)) + .leftJoin(Db.users, eq(Db.videos.ownerId, Db.users.id)) + .leftJoin(Db.videoUploads, eq(Db.videos.id, Db.videoUploads.videoId)) + .leftJoin(Db.organizations, eq(Db.videos.orgId, Db.organizations.id)) + .where(whereClause) + .groupBy( + Db.videos.id, + Db.videos.name, + Db.videos.createdAt, + Db.videos.updatedAt, + Db.users.name, + Db.videos.duration, + Db.videos.folderId, + Db.videos.public, + Db.videos.password, + Db.videoUploads.videoId, + Db.videoUploads.uploaded, + Db.videoUploads.total, + Db.videoUploads.phase, + Db.videoUploads.processingProgress, + Db.videoUploads.processingMessage, + Db.videoUploads.processingError, + Db.videos.metadata, + Db.videos.transcriptionStatus, + ) + .orderBy(desc(Db.videos.effectiveCreatedAt)) + .limit(limit) + .offset(offset), + ); + + return { rows, total: totalRow?.value ?? 0 }; +}); + +const getCapsList = Effect.fn("Mobile.getCapsList")(function* ( + params: (typeof Mobile.MobileCapsListParams)["Type"], +) { + const page = parsePositiveInteger(params.page, 1, 10_000); + const limit = parsePositiveInteger(params.limit, 20, 50); + const folderId = params.folderId + ? Folder.FolderId.make(params.folderId) + : null; + const videos = yield* Videos; + const user = yield* CurrentUser; + + const [{ rows, total }, folders] = yield* Effect.all([ + getCapRows({ folderId, page, limit }), + folderId ? Effect.succeed([]) : getRootFolders(user.activeOrganizationId), + ]); + const analyticsExits = yield* videos + .getAnalyticsBulk(rows.map((row) => row.id)) + .pipe(Effect.catchAll(() => Effect.succeed([]))); + const viewCounts = new Map(); + + rows.forEach((row, index) => { + const result = analyticsExits[index]; + viewCounts.set( + row.id, + result && Exit.isSuccess(result) ? result.value.count : 0, + ); + }); + + const caps = yield* Effect.forEach( + rows, + (row) => + videos.getThumbnailURL(row.id).pipe( + Effect.map(Option.getOrNull), + Effect.catchAll(() => Effect.succeed(null)), + Effect.map((thumbnailUrl) => + toMobileCapSummary(row, thumbnailUrl, viewCounts.get(row.id) ?? 0), + ), + ), + { concurrency: 5 }, + ); + + return { + folders, + caps, + page, + limit, + total, + hasMore: page * limit < total, + }; +}); + +const createMobileFolder = Effect.fn("Mobile.createFolder")(function* ( + input: MobileFolderCreateInput, +) { + const user = yield* CurrentUser; + const name = input.name.trim(); + if (!name) return yield* Effect.fail(new HttpApiError.BadRequest()); + + const organizationId = user.activeOrganizationId; + yield* assertOrganizationAccess(organizationId); + + const color = input.color ?? "normal"; + const id = Folder.FolderId.make(nanoId()); + const database = yield* Database; + + yield* database.use((db) => + db.insert(Db.folders).values({ + id, + name, + color, + organizationId, + createdById: user.id, + parentId: null, + spaceId: null, + }), + ); + + yield* Effect.sync(() => { + revalidatePath("/dashboard/caps"); + }); + + return { + id, + name, + color, + parentId: null, + videoCount: 0, + }; +}); + +const getCapById = Effect.fn("Mobile.getCapById")(function* ( + videoId: Video.VideoId, +) { + const user = yield* CurrentUser; + const database = yield* Database; + const videos = yield* Videos; + + const [row] = yield* database.use((db) => + db + .select({ + id: Db.videos.id, + name: Db.videos.name, + createdAt: Db.videos.createdAt, + updatedAt: Db.videos.updatedAt, + ownerName: Db.users.name, + duration: Db.videos.duration, + folderId: Db.videos.folderId, + public: Db.videos.public, + hasPassword: sql`${Db.videos.password} IS NOT NULL`.mapWith( + Boolean, + ), + commentCount: sql`COUNT(DISTINCT CASE WHEN ${Db.comments.type} = 'text' THEN ${Db.comments.id} END)`, + reactionCount: sql`COUNT(DISTINCT CASE WHEN ${Db.comments.type} = 'emoji' THEN ${Db.comments.id} END)`, + uploadVideoId: Db.videoUploads.videoId, + uploadUploaded: Db.videoUploads.uploaded, + uploadTotal: Db.videoUploads.total, + uploadPhase: Db.videoUploads.phase, + processingProgress: Db.videoUploads.processingProgress, + processingMessage: Db.videoUploads.processingMessage, + processingError: Db.videoUploads.processingError, + metadata: Db.videos.metadata, + transcriptionStatus: Db.videos.transcriptionStatus, + }) + .from(Db.videos) + .leftJoin(Db.comments, eq(Db.videos.id, Db.comments.videoId)) + .leftJoin(Db.users, eq(Db.videos.ownerId, Db.users.id)) + .leftJoin(Db.videoUploads, eq(Db.videos.id, Db.videoUploads.videoId)) + .where(and(eq(Db.videos.id, videoId), eq(Db.videos.ownerId, user.id))) + .groupBy( + Db.videos.id, + Db.videos.name, + Db.videos.createdAt, + Db.videos.updatedAt, + Db.users.name, + Db.videos.duration, + Db.videos.folderId, + Db.videos.public, + Db.videos.password, + Db.videoUploads.videoId, + Db.videoUploads.uploaded, + Db.videoUploads.total, + Db.videoUploads.phase, + Db.videoUploads.processingProgress, + Db.videoUploads.processingMessage, + Db.videoUploads.processingError, + Db.videos.metadata, + Db.videos.transcriptionStatus, + ), + ); + + if (!row) return yield* Effect.fail(new HttpApiError.NotFound()); + + const thumbnailUrl = yield* videos.getThumbnailURL(row.id).pipe( + Effect.map(Option.getOrNull), + Effect.catchAll(() => Effect.succeed(null)), + ); + const analytics = yield* videos.getAnalytics(row.id).pipe( + Effect.map((result) => result.count), + Effect.catchAll(() => Effect.succeed(0)), + ); + + return { row, cap: toMobileCapSummary(row, thumbnailUrl, analytics) }; +}); + +const getComments = Effect.fn("Mobile.getComments")(function* ( + videoId: Video.VideoId, +) { + const database = yield* Database; + const imageUploads = yield* ImageUploads; + + const rows = yield* database.use((db) => + db + .select({ + id: Db.comments.id, + videoId: Db.comments.videoId, + type: Db.comments.type, + content: Db.comments.content, + timestamp: Db.comments.timestamp, + parentCommentId: Db.comments.parentCommentId, + createdAt: Db.comments.createdAt, + updatedAt: Db.comments.updatedAt, + authorId: Db.comments.authorId, + authorName: Db.users.name, + authorImage: Db.users.image, + }) + .from(Db.comments) + .leftJoin(Db.users, eq(Db.comments.authorId, Db.users.id)) + .where(eq(Db.comments.videoId, videoId)) + .orderBy(Db.comments.createdAt), + ); + + return yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const imageUrl = row.authorImage + ? yield* imageUploads + .resolveImageUrl(row.authorImage) + .pipe(Effect.catchAll(() => Effect.succeed(null))) + : null; + + return { + id: row.id, + videoId: row.videoId, + type: row.type, + content: row.content, + timestamp: row.timestamp, + parentCommentId: row.parentCommentId, + createdAt: toIsoString(row.createdAt), + updatedAt: toIsoString(row.updatedAt), + author: { + id: row.authorId, + name: row.authorName, + imageUrl, + }, + }; + }), + { concurrency: 5 }, + ); +}); + +const getCapDetail = Effect.fn("Mobile.getCapDetail")(function* ( + videoId: Video.VideoId, +) { + const { row, cap } = yield* getCapById(videoId); + const metadata = getMetadataRecord(row.metadata); + const comments = yield* getComments(videoId); + + return { + cap, + summary: getMetadataString(metadata, "summary"), + chapters: getMetadataChapters(metadata), + transcriptionStatus: row.transcriptionStatus, + comments, + shareUrl: `${serverEnv().WEB_URL}/s/${videoId}`, + }; +}); + +const createMobileComment = Effect.fn("Mobile.createComment")(function* ({ + videoId, + content, + timestamp, + parentCommentId, + type, +}: { + videoId: Video.VideoId; + content: string; + timestamp: number | null; + parentCommentId: Comment.CommentId | null; + type: "text" | "emoji"; +}) { + const user = yield* CurrentUser; + yield* getCapById(videoId); + + const trimmedContent = content.trim(); + if (trimmedContent.length === 0) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const id = Comment.CommentId.make(nanoId()); + const now = new Date(); + const database = yield* Database; + yield* database.use((db) => + db.insert(Db.comments).values({ + id, + authorId: user.id, + type, + content: trimmedContent, + videoId, + timestamp, + parentCommentId, + createdAt: now, + updatedAt: now, + }), + ); + + const notificationType = parentCommentId + ? "reply" + : type === "emoji" + ? "reaction" + : "comment"; + + yield* Effect.tryPromise(() => + createNotification({ + type: notificationType, + videoId, + authorId: user.id, + comment: { id, content: trimmedContent }, + parentCommentId: parentCommentId ?? undefined, + }), + ).pipe(Effect.catchAll(() => Effect.void)); + + const comments = yield* getComments(videoId); + const created = comments.find((comment) => comment.id === id); + if (!created) + return yield* Effect.fail(new HttpApiError.InternalServerError()); + return created; +}); + +const getPlayback = Effect.fn("Mobile.getPlayback")(function* ( + videoId: Video.VideoId, +) { + const videos = yield* Videos; + const storage = yield* Storage; + const [video] = yield* videos.getByIdForViewing(videoId).pipe( + Effect.flatten, + Effect.catchTag("NoSuchElementException", () => new Video.NotFoundError()), + ); + const [bucket] = yield* storage.getAccessForVideo(video); + const source = Video.Video.getSource(video); + + const transcriptKey = `${video.ownerId}/${video.id}/transcription.vtt`; + const transcriptUrl = yield* bucket.headObject(transcriptKey).pipe( + Effect.flatMap(() => bucket.getSignedObjectUrl(transcriptKey)), + Effect.catchAll(() => Effect.succeed(null)), + ); + + if (source instanceof Video.Mp4Source) { + const url = yield* bucket.getSignedObjectUrl(source.getFileKey()); + return { kind: "mp4" as const, url, transcriptUrl }; + } + + if (source instanceof Video.M3U8Source) { + const url = yield* bucket.getSignedObjectUrl(source.getPlaylistFileKey()); + return { kind: "hls" as const, url, transcriptUrl }; + } + + if (source instanceof Video.SegmentsSource) { + return { + kind: "hls" as const, + url: `${serverEnv().WEB_URL}/api/playlist?videoId=${video.id}&videoType=segments-master`, + transcriptUrl, + }; + } + + return yield* Effect.fail(new HttpApiError.NotFound()); +}); + +const createUpload = Effect.fn("Mobile.createUpload")(function* ( + input: MobileUploadCreateInput, +) { + const user = yield* CurrentUser; + const organizationId = input.organizationId ?? user.activeOrganizationId; + yield* assertOrganizationAccess(organizationId); + + if (!input.contentType.startsWith("video/")) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const database = yield* Database; + const storage = yield* Storage; + const repo = yield* VideosRepo; + const folderId = input.folderId; + + if (folderId) { + const [folder] = yield* database.use((db) => + db + .select({ id: Db.folders.id }) + .from(Db.folders) + .where( + and( + eq(Db.folders.id, folderId), + eq(Db.folders.organizationId, organizationId), + eq(Db.folders.createdById, user.id), + isNull(Db.folders.spaceId), + ), + ), + ); + if (!folder) return yield* Effect.fail(new HttpApiError.NotFound()); + } + + const writable = yield* storage.getWritableAccessForUser( + user.id, + organizationId, + ); + const videoId = yield* repo.create({ + ownerId: user.id, + orgId: organizationId, + name: getUploadTitle(input.fileName), + public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC, + source: { type: "webMP4" }, + bucketId: writable.bucketId, + storageIntegrationId: writable.storageIntegrationId, + folderId: Option.fromNullable(folderId), + width: Option.fromNullable(input.width), + height: Option.fromNullable(input.height), + duration: Option.fromNullable(input.durationSeconds), + metadata: Option.none(), + transcriptionStatus: Option.none(), + }); + + yield* database.use((db) => + db.insert(Db.videoUploads).values({ + videoId, + total: input.contentLength ?? 0, + mode: "singlepart", + }), + ); + + const rawFileKey = `${user.id}/${videoId}/raw-upload.${getFileExtension(input)}`; + const upload = yield* writable.access.createUploadTarget(rawFileKey, { + contentType: input.contentType, + method: "put", + fields: { + "Content-Type": input.contentType, + "x-amz-meta-userid": user.id, + "x-amz-meta-source": "cap-mobile-ios", + }, + }); + const { cap } = yield* getCapById(videoId); + + return { + id: videoId, + shareUrl: `${serverEnv().WEB_URL}/s/${videoId}`, + rawFileKey, + upload, + cap, + }; +}); + +const ApiLive = HttpApiBuilder.api(Mobile.MobileApiContract).pipe( + Layer.provide( + HttpApiBuilder.group(Mobile.MobileApiContract, "mobile", (handlers) => + Effect.gen(function* () { + const videos = yield* Videos; + const database = yield* Database; + + return handlers + .handle("getAuthConfig", () => + Effect.succeed({ + googleAuthAvailable: Boolean(serverEnv().GOOGLE_CLIENT_ID), + workosAuthAvailable: Boolean(serverEnv().WORKOS_CLIENT_ID), + }), + ) + .handle("requestSession", ({ request, urlParams }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* getCurrentUser; + if (Option.isNone(user)) { + const redirectOrigin = getDeploymentOrigin(); + const requestUrl = new URL(request.url); + const loginRedirectUrl = new URL(`${redirectOrigin}/login`); + loginRedirectUrl.searchParams.set( + "next", + new URL( + `${redirectOrigin}${requestUrl.pathname}${requestUrl.search}`, + ).toString(), + ); + if (urlParams.provider === "google") { + loginRedirectUrl.searchParams.set( + "mobileProvider", + "google", + ); + } else if (urlParams.provider === "workos") { + loginRedirectUrl.searchParams.set( + "mobileProvider", + "workos", + ); + if (urlParams.organizationId) { + loginRedirectUrl.searchParams.set( + "organizationId", + urlParams.organizationId, + ); + } + } + return HttpServerResponse.redirect( + loginRedirectUrl.toString(), + ); + } + + const session = yield* createMobileApiKey(user.value.id); + + if (urlParams.redirectUri) { + const redirectUrl = new URL(urlParams.redirectUri); + redirectUrl.searchParams.set("api_key", session.apiKey); + redirectUrl.searchParams.set("user_id", user.value.id); + return HttpServerResponse.redirect(redirectUrl.toString()); + } + + return session; + }), + ), + ) + .handle("requestEmailSession", ({ payload }) => + withMappedErrors(requestEmailSession(payload.email)), + ) + .handle("verifyEmailSession", ({ payload }) => + withMappedErrors(verifyEmailSession(payload)), + ) + .handle("revokeSession", ({ headers }) => + withMappedErrors( + Effect.gen(function* () { + const token = parseBearerToken(headers.authorization); + if (!token) + return yield* Effect.fail(new HttpApiError.Unauthorized()); + yield* database.use((db) => + db.delete(Db.authApiKeys).where(eq(Db.authApiKeys.id, token)), + ); + return { success: true as const }; + }), + ), + ) + .handle("bootstrap", () => withMappedErrors(getBootstrap())) + .handle("setActiveOrganization", ({ payload }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + yield* assertOrganizationAccess(payload.organizationId); + yield* database.use((db) => + db + .update(Db.users) + .set({ activeOrganizationId: payload.organizationId }) + .where(eq(Db.users.id, user.id)), + ); + return yield* getBootstrap(); + }), + ), + ) + .handle("listCaps", ({ urlParams }) => + withMappedErrors(getCapsList(urlParams)), + ) + .handle("createFolder", ({ payload }) => + withMappedErrors(createMobileFolder(payload)), + ) + .handle("getCap", ({ path }) => + withMappedErrors(getCapDetail(path.id)), + ) + .handle("updateCapSharing", ({ path, payload }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + yield* getCapById(path.id); + yield* database.use((db) => + db + .update(Db.videos) + .set({ public: payload.public }) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, user.id), + ), + ), + ); + const { cap } = yield* getCapById(path.id); + return cap; + }), + ), + ) + .handle("updateCapTitle", ({ path, payload }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + yield* getCapById(path.id); + const title = payload.title.trim(); + if (!title) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + yield* database.use((db) => + db + .update(Db.videos) + .set({ name: title }) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, user.id), + ), + ), + ); + yield* Effect.sync(() => { + revalidatePath("/dashboard/caps"); + revalidatePath("/dashboard/shared-caps"); + revalidatePath(`/s/${path.id}`); + }); + const { cap } = yield* getCapById(path.id); + return cap; + }), + ), + ) + .handle("updateCapPassword", ({ path, payload }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + yield* getCapById(path.id); + const trimmedPassword = payload.password?.trim() ?? null; + const nextPassword = trimmedPassword + ? yield* Effect.tryPromise({ + try: () => hashPassword(trimmedPassword), + catch: () => new HttpApiError.InternalServerError(), + }) + : null; + + yield* database.use((db) => + db + .update(Db.videos) + .set({ password: nextPassword }) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, user.id), + ), + ), + ); + const { cap } = yield* getCapById(path.id); + return cap; + }), + ), + ) + .handle("deleteCap", ({ path }) => + withMappedErrors( + videos + .delete(path.id) + .pipe(Effect.map(() => ({ success: true as const }))), + ), + ) + .handle("getPlayback", ({ path }) => + withMappedErrors(getPlayback(path.id)), + ) + .handle("getDownload", ({ path }) => + withMappedErrors( + videos.getDownloadInfo(path.id).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new HttpApiError.NotFound()), + onSome: (info) => + Effect.succeed({ + fileName: info.fileName, + url: info.downloadUrl, + }), + }), + ), + ), + ), + ) + .handle("createComment", ({ path, payload }) => + withMappedErrors( + createMobileComment({ + videoId: path.id, + content: payload.content, + timestamp: payload.timestamp, + parentCommentId: payload.parentCommentId ?? null, + type: "text", + }), + ), + ) + .handle("deleteComment", ({ path }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + const result = yield* database.use((db) => + db + .delete(Db.comments) + .where( + and( + eq(Db.comments.id, path.id), + eq(Db.comments.authorId, user.id), + ), + ), + ); + const affectedRows = Array.isArray(result) + ? (result[0]?.affectedRows ?? 0) + : 0; + if (affectedRows === 0) { + return yield* Effect.fail(new HttpApiError.NotFound()); + } + return { success: true as const }; + }), + ), + ) + .handle("createReaction", ({ path, payload }) => + withMappedErrors( + createMobileComment({ + videoId: path.id, + content: payload.content, + timestamp: payload.timestamp, + parentCommentId: null, + type: "emoji", + }), + ), + ) + .handle("createUpload", ({ payload }) => + withMappedErrors(createUpload(payload)), + ) + .handle("updateUploadProgress", ({ path, payload }) => + withMappedErrors( + videos + .updateUploadProgress({ + videoId: path.id, + uploaded: Math.max(0, Math.trunc(payload.uploaded)), + total: Math.max(0, Math.trunc(payload.total)), + updatedAt: new Date(), + }) + .pipe(Effect.map(() => ({ success: true as const }))), + ), + ) + .handle("completeUpload", ({ path, payload }) => + withMappedErrors( + Effect.gen(function* () { + const user = yield* CurrentUser; + const [video] = yield* database.use((db) => + db + .select({ + id: Db.videos.id, + ownerId: Db.videos.ownerId, + bucketId: Db.videos.bucket, + }) + .from(Db.videos) + .where( + and( + eq(Db.videos.id, path.id), + eq(Db.videos.ownerId, user.id), + ), + ), + ); + if (!video) + return yield* Effect.fail(new HttpApiError.NotFound()); + + const prefix = `${user.id}/${path.id}/`; + if (!payload.rawFileKey.startsWith(prefix)) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + if (payload.contentLength !== undefined) { + yield* database.use((db) => + db + .update(Db.videoUploads) + .set({ + uploaded: payload.contentLength, + total: payload.contentLength, + updatedAt: new Date(), + }) + .where(eq(Db.videoUploads.videoId, path.id)), + ); + } + + yield* Effect.tryPromise(() => + startVideoProcessingWorkflow({ + videoId: path.id, + userId: user.id, + rawFileKey: payload.rawFileKey, + bucketId: video.bucketId, + processingMessage: "Starting video processing...", + startFailureMessage: + "Video uploaded, but processing could not start.", + mode: "singlepart", + }), + ).pipe( + Effect.catchAll((error) => + Effect.logError(error).pipe( + Effect.flatMap(() => + Effect.fail(new HttpApiError.InternalServerError()), + ), + ), + ), + ); + + return { success: true as const }; + }), + ), + ); + }), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const GET = handler; +export const POST = handler; +export const PATCH = handler; +export const DELETE = handler; From 7f3c8f79016495d51432dd4176e4d7f7a44069a3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 04/20] test(web): add mobile API contract tests --- .../unit/mobile-api-contract.test.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 apps/web/__tests__/unit/mobile-api-contract.test.ts diff --git a/apps/web/__tests__/unit/mobile-api-contract.test.ts b/apps/web/__tests__/unit/mobile-api-contract.test.ts new file mode 100644 index 0000000000..c336c187c1 --- /dev/null +++ b/apps/web/__tests__/unit/mobile-api-contract.test.ts @@ -0,0 +1,174 @@ +import { Folder, Mobile, Organisation, User, Video } from "@cap/web-domain"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +describe("mobile API contract schemas", () => { + it("decodes bootstrap responses without exposing database rows", () => { + const decoded = Schema.decodeUnknownSync(Mobile.MobileBootstrapResponse)({ + user: { + id: User.UserId.make("user_123"), + name: "Richie", + email: "richie@example.com", + imageUrl: null, + activeOrganizationId: Organisation.OrganisationId.make("org_123"), + }, + organizations: [ + { + id: Organisation.OrganisationId.make("org_123"), + name: "Cap", + iconUrl: null, + role: "owner", + }, + ], + activeOrganizationId: Organisation.OrganisationId.make("org_123"), + rootFolders: [ + { + id: Folder.FolderId.make("folder_123"), + name: "Product", + color: "blue", + parentId: null, + videoCount: 4, + }, + ], + }); + + expect(decoded.user.email).toBe("richie@example.com"); + expect(decoded.rootFolders[0]?.videoCount).toBe(4); + }); + + it("decodes auth provider availability", () => { + const decoded = Schema.decodeUnknownSync(Mobile.MobileAuthConfigResponse)({ + googleAuthAvailable: true, + workosAuthAvailable: false, + }); + + expect(decoded.googleAuthAvailable).toBe(true); + expect(decoded.workosAuthAvailable).toBe(false); + }); + + it("accepts Google and WorkOS mobile session providers", () => { + expect( + Schema.decodeUnknownSync(Mobile.MobileSessionRequestParams)({ + redirectUri: "cap://auth", + provider: "google", + }).provider, + ).toBe("google"); + expect( + Schema.decodeUnknownSync(Mobile.MobileSessionRequestParams)({ + redirectUri: "cap://auth", + provider: "workos", + organizationId: "org_123", + }).organizationId, + ).toBe("org_123"); + }); + + it("decodes Cap sharing visibility updates", () => { + const decoded = Schema.decodeUnknownSync(Mobile.MobileCapSharingInput)({ + public: false, + }); + + expect(decoded.public).toBe(false); + }); + + it("decodes Cap title updates", () => { + const decoded = Schema.decodeUnknownSync(Mobile.MobileCapTitleInput)({ + title: "Roadmap review", + }); + + expect(decoded.title).toBe("Roadmap review"); + }); + + it("decodes Cap password updates", () => { + expect( + Schema.decodeUnknownSync(Mobile.MobileCapPasswordInput)({ + password: "secret", + }).password, + ).toBe("secret"); + expect( + Schema.decodeUnknownSync(Mobile.MobileCapPasswordInput)({ + password: null, + }).password, + ).toBeNull(); + }); + + it("decodes mobile folder creation inputs", () => { + const decoded = Schema.decodeUnknownSync(Mobile.MobileFolderCreateInput)({ + name: "Product", + color: "blue", + }); + + expect(decoded).toEqual({ + name: "Product", + color: "blue", + }); + }); + + it("requires mobile caps dates to be serialized strings", () => { + expect(() => + Schema.decodeUnknownSync(Mobile.MobileCapSummary)({ + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: new Date("2026-05-18T10:00:00.000Z"), + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: 125, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 7, + commentCount: 2, + reactionCount: 3, + upload: null, + }), + ).toThrow(); + }); + + it("decodes signed playback and upload targets", () => { + const playback = Schema.decodeUnknownSync(Mobile.MobilePlaybackResponse)({ + kind: "mp4", + url: "https://signed.example/video.mp4", + transcriptUrl: "https://signed.example/transcript.vtt", + }); + const upload = Schema.decodeUnknownSync(Mobile.MobileUploadCreateResponse)({ + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + rawFileKey: "user_123/video_123/raw-upload.mp4", + upload: { + type: "put", + url: "https://signed.example/upload", + headers: { + "Content-Type": "video/mp4", + }, + }, + cap: { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: { + uploaded: 0, + total: 0, + phase: "uploading", + processingProgress: 0, + processingMessage: null, + processingError: null, + }, + }, + }); + + expect(playback.url).toContain("signed.example"); + expect(upload.upload.type).toBe("put"); + }); +}); From 8197e4c3da36b0446edd0dd95a01e5f0d3a92702 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 05/20] feat(web): support mobile auth deep links on login form --- apps/web/app/(org)/login/form.tsx | 71 +++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 50ffff2733..81008579e4 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -15,7 +15,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { signIn } from "next-auth/react"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useId, useState } from "react"; import { toast } from "sonner"; import { getOrganizationSSOData } from "@/actions/organization/get-organization-sso-data"; import { trackEvent } from "@/app/utils/analytics"; @@ -43,10 +43,7 @@ export function LoginForm() { const theme = Cookies.get("theme") || "light"; useEffect(() => { - theme === "dark" - ? (document.body.className = "dark") - : (document.body.className = "light"); - //remove the dark mode when we leave the dashboard + document.body.className = theme === "dark" ? "dark" : "light"; return () => { document.body.className = "light"; }; @@ -104,7 +101,7 @@ export function LoginForm() { } }, [emailSent]); - const handleGoogleSignIn = () => { + const handleGoogleSignIn = useCallback(() => { trackEvent("auth_started", { method: "google", is_signup: false, @@ -113,7 +110,50 @@ export function LoginForm() { signIn("google", { ...(next && next.length > 0 ? { callbackUrl: next } : {}), }); - }; + }, [next]); + + const handleWorkosSignIn = useCallback( + async (orgId: string) => { + const data = await getOrganizationSSOData( + Organisation.OrganisationId.make(orgId), + ); + setOrganizationName(data.name); + + signIn( + "workos", + next && next.length > 0 ? { callbackUrl: next } : undefined, + { + organization: data.organizationId, + connection: data.connectionId, + }, + ); + }, + [next], + ); + + useEffect(() => { + if (searchParams?.get("mobileProvider") === "google") { + handleGoogleSignIn(); + } + + if (searchParams?.get("mobileProvider") !== "workos") return; + const mobileOrganizationId = searchParams.get("organizationId"); + if (!mobileOrganizationId) { + setShowOrgInput(true); + return; + } + + let active = true; + handleWorkosSignIn(mobileOrganizationId).catch(() => { + if (!active) return; + setOrganizationId(mobileOrganizationId); + setShowOrgInput(true); + toast.error("Organization not found or SSO not configured"); + }); + return () => { + active = false; + }; + }, [handleGoogleSignIn, handleWorkosSignIn, searchParams]); const handleOrganizationLookup = async (e: React.FormEvent) => { e.preventDefault(); @@ -123,15 +163,7 @@ export function LoginForm() { } try { - const data = await getOrganizationSSOData( - Organisation.OrganisationId.make(organizationId), - ); - setOrganizationName(data.name); - - signIn("workos", undefined, { - organization: data.organizationId, - connection: data.connectionId, - }); + await handleWorkosSignIn(organizationId); } catch (error) { console.error("Lookup Error:", error); toast.error("Organization not found or SSO not configured"); @@ -372,6 +404,8 @@ const LoginWithSSO = ({ setOrganizationId: (organizationId: string) => void; organizationName: string | null; }) => { + const organizationIdInputId = useId(); + return ( setOrganizationId(e.target.value)} @@ -415,12 +449,13 @@ const NormalLogin = ({ handleGoogleSignIn: () => void; }) => { const publicEnv = usePublicEnv(); + const emailInputId = useId(); return ( Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 06/20] chore(mobile): add Expo app scaffold and assets --- apps/mobile/.gitignore | 13 +++ apps/mobile/app.config.js | 67 ++++++++++++++ apps/mobile/assets/icon.png | Bin 0 -> 57866 bytes apps/mobile/assets/icon.svg | 6 ++ apps/mobile/assets/splash-icon.png | Bin 0 -> 28643 bytes apps/mobile/assets/splash-icon.svg | 5 + apps/mobile/babel.config.js | 7 ++ apps/mobile/metro.config.js | 14 +++ apps/mobile/package.json | 60 ++++++++++++ apps/mobile/scripts/run-ios-simulator.mjs | 107 ++++++++++++++++++++++ apps/mobile/tsconfig.json | 22 +++++ apps/mobile/vitest.config.ts | 14 +++ 12 files changed, 315 insertions(+) create mode 100644 apps/mobile/.gitignore create mode 100644 apps/mobile/app.config.js create mode 100644 apps/mobile/assets/icon.png create mode 100644 apps/mobile/assets/icon.svg create mode 100644 apps/mobile/assets/splash-icon.png create mode 100644 apps/mobile/assets/splash-icon.svg create mode 100644 apps/mobile/babel.config.js create mode 100644 apps/mobile/metro.config.js create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/scripts/run-ios-simulator.mjs create mode 100644 apps/mobile/tsconfig.json create mode 100644 apps/mobile/vitest.config.ts diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000000..6dd4bbef84 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,13 @@ +/node_modules +.expo +/ios +/android +dist +coverage +*.tsbuildinfo + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js new file mode 100644 index 0000000000..7d6f2acd26 --- /dev/null +++ b/apps/mobile/app.config.js @@ -0,0 +1,67 @@ +const associatedDomains = + process.env.CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS === "1" + ? [] + : process.env.CAP_MOBILE_ASSOCIATED_DOMAINS + ? process.env.CAP_MOBILE_ASSOCIATED_DOMAINS.split(",") + .map((domain) => domain.trim()) + .filter(Boolean) + : ["applinks:cap.so"]; +const bundleIdentifier = "so.cap.mobile"; +const ios = { + bundleIdentifier, + supportsTablet: false, + infoPlist: { + NSPhotoLibraryUsageDescription: + "Cap imports videos from Photos for upload.", + NSPhotoLibraryAddUsageDescription: "Cap saves downloaded videos to Photos.", + UIBackgroundModes: ["processing"], + }, +}; + +if (associatedDomains.length > 0) { + ios.associatedDomains = associatedDomains; +} + +module.exports = ({ config }) => ({ + ...config, + name: "Cap", + slug: "cap-mobile", + scheme: "cap", + owner: "cap", + version: "0.1.0", + orientation: "portrait", + platforms: ["ios"], + userInterfaceStyle: "light", + icon: "./assets/icon.png", + splash: { + image: "./assets/splash-icon.png", + resizeMode: "contain", + backgroundColor: "#f9f9f9", + }, + ios, + experiments: { + typedRoutes: true, + }, + plugins: [ + "expo-router", + [ + "expo-font", + { + fonts: [ + "../web/public/fonts/NeueMontreal-Regular.otf", + "../web/public/fonts/NeueMontreal-Medium.otf", + "../web/public/fonts/NeueMontreal-Bold.otf", + ], + }, + ], + [ + "expo-secure-store", + { + faceIDPermission: "Allow Cap to protect your account key.", + }, + ], + ], + extra: { + apiBaseUrl: process.env.EXPO_PUBLIC_CAP_WEB_URL ?? "https://cap.so", + }, +}); diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b81ddeeef92f6fa221857d8702052f14a1e2cf4c GIT binary patch literal 57866 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 + + + + + diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b480ea2b880306327a003874139deb349183a4c1 GIT binary patch literal 28643 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 qKCK3-c7Uf2$eI4%{j*>}Fbb + + + + diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js new file mode 100644 index 0000000000..8f92bed32c --- /dev/null +++ b/apps/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = (api) => { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: ["react-native-reanimated/plugin"], + }; +}; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000000..9c8cf0a0e8 --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,14 @@ +const path = require("node:path"); +const { getDefaultConfig } = require("expo/metro-config"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, "../.."); +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [workspaceRoot]; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), +]; + +module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000000..868773eb45 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,60 @@ +{ + "name": "@cap/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "dev": "CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS=1 node scripts/run-ios-simulator.mjs", + "dev:device": "expo run:ios --device", + "start": "expo start --dev-client", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prebuild:ios": "expo prebuild --platform ios --no-install", + "ios": "expo run:ios" + }, + "dependencies": { + "@cap/web-domain": "workspace:*", + "@expo/config-plugins": "~55.0.9", + "@expo/metro-runtime": "~55.0.11", + "@shopify/flash-list": "2.0.2", + "effect": "^3.18.4", + "expo": "~55.0.24", + "expo-clipboard": "~55.0.13", + "expo-constants": "~55.0.16", + "expo-dev-client": "~55.0.34", + "expo-document-picker": "~55.0.13", + "expo-file-system": "~55.0.20", + "expo-font": "~55.0.7", + "expo-glass-effect": "~55.0.11", + "expo-image": "~55.0.10", + "expo-image-picker": "~55.0.20", + "expo-linking": "~55.0.15", + "expo-media-library": "~55.0.17", + "expo-modules-core": "~55.0.25", + "expo-router": "~55.0.14", + "expo-secure-store": "~55.0.14", + "expo-sharing": "~55.0.19", + "expo-symbols": "~55.0.8", + "expo-video": "~55.0.17", + "expo-web-browser": "~55.0.16", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-native": "0.83.6", + "react-native-gesture-handler": "~2.30.0", + "react-native-reanimated": "4.2.1", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.23.0", + "react-native-svg": "15.15.5", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.7.4" + }, + "devDependencies": { + "@testing-library/react-native": "^13.3.3", + "@types/react": "19.2.14", + "@types/react-test-renderer": "^19.1.0", + "babel-preset-expo": "~55.0.21", + "react-test-renderer": "19.2.0", + "typescript": "~5.9.2", + "vitest": "^3.2.0" + } +} diff --git a/apps/mobile/scripts/run-ios-simulator.mjs b/apps/mobile/scripts/run-ios-simulator.mjs new file mode 100644 index 0000000000..1ea40fa0d7 --- /dev/null +++ b/apps/mobile/scripts/run-ios-simulator.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const readSimulators = () => { + const result = spawnSync( + "xcrun", + ["simctl", "list", "devices", "available", "--json"], + { + encoding: "utf8", + }, + ); + if (result.status !== 0) { + throw new Error(result.stderr || "Unable to list iOS simulators"); + } + + return JSON.parse(result.stdout); +}; + +const findSimulator = () => { + const requestedUdid = process.env.IOS_SIMULATOR_UDID; + const requestedName = process.env.IOS_SIMULATOR_DEVICE; + const data = readSimulators(); + const devices = Object.values(data.devices ?? {}) + .flat() + .filter( + (device) => device?.isAvailable && device?.name?.includes("iPhone"), + ); + + if (requestedUdid) { + const requested = devices.find((device) => device.udid === requestedUdid); + if (requested) return requested; + throw new Error(`No available iPhone simulator found for ${requestedUdid}`); + } + + if (requestedName) { + const requested = devices.find((device) => device.name === requestedName); + if (requested) return requested; + throw new Error(`No available iPhone simulator named ${requestedName}`); + } + + const booted = devices.find((device) => device.state === "Booted"); + if (booted) return booted; + + const preferred = devices.find((device) => device.name.includes("Pro")); + return preferred ?? devices[0] ?? null; +}; + +const simulator = findSimulator(); +if (!simulator) { + throw new Error("No available iPhone simulators found"); +} + +const needsDevPrebuild = () => { + if (existsSync(join(process.cwd(), "ios", "CapBroadcastExtension"))) { + return true; + } + const entitlementsPath = join( + process.cwd(), + "ios", + "Cap", + "Cap.entitlements", + ); + if (!existsSync(entitlementsPath)) return true; + const entitlements = readFileSync(entitlementsPath, "utf8"); + return entitlements.includes("com.apple.developer.associated-domains"); +}; + +const command = ["exec", "expo", "run:ios", "--device", simulator.udid]; +console.log(`Using iOS simulator: ${simulator.name} (${simulator.udid})`); + +if (process.env.CAP_MOBILE_DRY_RUN === "1") { + console.log(`pnpm ${command.join(" ")}`); + process.exit(0); +} + +if ( + process.env.CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS === "1" && + needsDevPrebuild() +) { + const prebuild = spawnSync( + "pnpm", + [ + "exec", + "expo", + "prebuild", + "--platform", + "ios", + "--no-install", + "--clean", + ], + { + stdio: "inherit", + env: process.env, + }, + ); + if (prebuild.status !== 0) { + process.exit(prebuild.status ?? 1); + } +} + +const result = spawnSync("pnpm", command, { + stdio: "inherit", + env: process.env, +}); + +process.exit(result.status ?? 1); diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000000..495df8487c --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "allowImportingTsExtensions": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@modules/*": ["modules/*"] + }, + "types": ["vitest/globals"] + }, + "include": [ + "app", + "src", + "modules", + "plugins", + "expo-env.d.ts", + "*.js", + ".expo/types/**/*.ts" + ] +} diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts new file mode 100644 index 0000000000..d60d49c3b9 --- /dev/null +++ b/apps/mobile/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + jsxImportSource: "react", + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +}); From 0201c681f269ad991f97d5250b08dd5fe87cd465 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 07/20] feat(mobile): add theme and format utilities --- apps/mobile/src/theme.test.ts | 63 ++++++++++++++++++ apps/mobile/src/theme.ts | 95 ++++++++++++++++++++++++++++ apps/mobile/src/utils/format.test.ts | 35 ++++++++++ apps/mobile/src/utils/format.ts | 51 +++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 apps/mobile/src/theme.test.ts create mode 100644 apps/mobile/src/theme.ts create mode 100644 apps/mobile/src/utils/format.test.ts create mode 100644 apps/mobile/src/utils/format.ts diff --git a/apps/mobile/src/theme.test.ts b/apps/mobile/src/theme.test.ts new file mode 100644 index 0000000000..13a0898d49 --- /dev/null +++ b/apps/mobile/src/theme.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { colors } from "./theme"; + +vi.mock("react-native", () => ({ + StyleSheet: { + create: >(styles: T) => styles, + }, +})); + +const webRadixColors = { + gray: { + gray1: "#fcfcfc", + gray2: "#f9f9f9", + gray3: "#f0f0f0", + gray4: "#e8e8e8", + gray5: "#e0e0e0", + gray6: "#d9d9d9", + gray7: "#cecece", + gray8: "#bbbbbb", + gray9: "#8d8d8d", + gray10: "#838383", + gray11: "#646464", + gray12: "#202020", + }, + blue: { + blue1: "#fbfdff", + blue2: "#f4faff", + blue3: "#e6f4fe", + blue4: "#d5efff", + blue5: "#c2e5ff", + blue6: "#acd8fc", + blue7: "#8ec8f6", + blue8: "#5eb1ef", + blue9: "#0090ff", + blue10: "#0588f0", + blue11: "#0d74ce", + blue12: "#113264", + }, + red: { + red1: "#fffcfc", + red2: "#fff7f7", + red3: "#feebec", + red4: "#ffdbdc", + red5: "#ffcdce", + red6: "#fdbdbe", + red7: "#f4a9aa", + red8: "#eb8e90", + red9: "#e5484d", + red10: "#dc3e42", + red11: "#ce2c31", + red12: "#641723", + }, +}; + +describe("mobile theme", () => { + it("matches the Radix color scales imported by Cap web", () => { + expect(colors).toMatchObject({ + ...webRadixColors.gray, + ...webRadixColors.blue, + ...webRadixColors.red, + }); + }); +}); diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts new file mode 100644 index 0000000000..786269c6b8 --- /dev/null +++ b/apps/mobile/src/theme.ts @@ -0,0 +1,95 @@ +import { StyleSheet } from "react-native"; + +export const colors = { + white: "#ffffff", + black: "#000000", + gray1: "#fcfcfc", + gray2: "#f9f9f9", + gray3: "#f0f0f0", + gray4: "#e8e8e8", + gray5: "#e0e0e0", + gray6: "#d9d9d9", + gray7: "#cecece", + gray8: "#bbbbbb", + gray9: "#8d8d8d", + gray10: "#838383", + gray11: "#646464", + gray12: "#202020", + appBackground: "#f9f9f9", + blue1: "#fbfdff", + blue2: "#f4faff", + blue3: "#e6f4fe", + blue4: "#d5efff", + blue5: "#c2e5ff", + blue6: "#acd8fc", + blue7: "#8ec8f6", + blue8: "#5eb1ef", + blue9: "#0090ff", + blue10: "#0588f0", + blue11: "#0d74ce", + blue12: "#113264", + red1: "#fffcfc", + red2: "#fff7f7", + red3: "#feebec", + red4: "#ffdbdc", + red5: "#ffcdce", + red6: "#fdbdbe", + red7: "#f4a9aa", + red8: "#eb8e90", + red9: "#e5484d", + red10: "#dc3e42", + red11: "#ce2c31", + red12: "#641723", + primary: "#005cb1", + primary2: "#004c93", + secondary: "#2eb4ff", + tertiary: "#c5eaff", + buttonBlue: "#2563eb", + buttonBlueHover: "#1d4ed8", + buttonBlueBorder: "#1e40af", + glass: "rgba(252, 252, 252, 0.72)", + blackAlpha5: "rgba(18, 22, 31, 0.05)", + blackAlpha10: "rgba(18, 22, 31, 0.1)", + blackAlpha40: "rgba(18, 22, 31, 0.4)", + blackAlpha60: "rgba(18, 22, 31, 0.6)", + green9: "#30a46c", + yellow3: "#fffab8", + yellow5: "#ffe770", + yellow9: "#f5d90a", +}; + +export const fonts = { + regular: "NeueMontreal-Regular", + medium: "NeueMontreal-Medium", + bold: "NeueMontreal-Bold", +}; + +export const radius = { + xs: 6, + sm: 8, + md: 12, + lg: 16, + xl: 20, + full: 999, +}; + +export const squircle = { + borderCurve: "continuous" as const, +}; + +export const shadows = StyleSheet.create({ + card: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 2, + elevation: 1, + }, + popover: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 0.12, + shadowRadius: 32, + elevation: 10, + }, +}); diff --git a/apps/mobile/src/utils/format.test.ts b/apps/mobile/src/utils/format.test.ts new file mode 100644 index 0000000000..1ef3985000 --- /dev/null +++ b/apps/mobile/src/utils/format.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration, formatFileSize, formatRelativeDate } from "./format"; + +describe("mobile formatters", () => { + it("formats card dates like Cap web", () => { + const now = new Date("2026-05-18T11:00:00.000Z"); + + expect(formatRelativeDate("2026-05-18T10:30:00.000Z", now)).toBe( + "30 minutes ago", + ); + expect(formatRelativeDate("2026-05-18T09:45:00.000Z", now)).toBe( + "an hour ago", + ); + expect(formatRelativeDate("2026-05-16T11:00:00.000Z", now)).toBe( + "2 days ago", + ); + }); + + it("formats card durations like Cap web thumbnails", () => { + expect(formatDuration(0)).toBe("< 1 sec"); + expect(formatDuration(8)).toBe("8 secs"); + expect(formatDuration(61)).toBe("1 min"); + expect(formatDuration(125)).toBe("2 mins"); + expect(formatDuration(7200)).toBe("2 hrs"); + }); + + it("formats native upload file sizes", () => { + expect(formatFileSize(null)).toBeNull(); + expect(formatFileSize(0)).toBeNull(); + expect(formatFileSize(640)).toBe("640 B"); + expect(formatFileSize(124_000)).toBe("124 KB"); + expect(formatFileSize(12_400_000)).toBe("12 MB"); + expect(formatFileSize(2_300_000_000)).toBe("2 GB"); + }); +}); diff --git a/apps/mobile/src/utils/format.ts b/apps/mobile/src/utils/format.ts new file mode 100644 index 0000000000..a8b43469f6 --- /dev/null +++ b/apps/mobile/src/utils/format.ts @@ -0,0 +1,51 @@ +export const formatRelativeDate = (input: string, now = new Date()) => { + const date = new Date(input); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.max(0, Math.round(diffMs / 1000)); + if (diffSeconds < 45) return "a few seconds ago"; + if (diffSeconds < 90) return "a minute ago"; + + const diffMinutes = Math.round(diffSeconds / 60); + if (diffMinutes < 45) return `${diffMinutes} minutes ago`; + if (diffMinutes < 90) return "an hour ago"; + + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 22) return `${diffHours} hours ago`; + if (diffHours < 36) return "a day ago"; + + const diffDays = Math.round(diffHours / 24); + if (diffDays < 26) return `${diffDays} days ago`; + if (diffDays < 45) return "a month ago"; + + const diffMonths = Math.round(diffDays / 30); + if (diffDays < 320) return `${diffMonths} months ago`; + if (diffDays < 548) return "a year ago"; + + const diffYears = Math.round(diffDays / 365); + return `${diffYears} years ago`; +}; + +export const formatDuration = (seconds: number | null) => { + if (seconds === null || !Number.isFinite(seconds)) return null; + const safeSeconds = Math.max(0, Math.ceil(seconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor(safeSeconds / 60); + const remainingSeconds = safeSeconds % 60; + if (hours > 0) return `${hours} hr${hours > 1 ? "s" : ""}`; + if (minutes > 0) return `${minutes} min${minutes > 1 ? "s" : ""}`; + if (remainingSeconds > 0) { + return `${remainingSeconds} sec${remainingSeconds === 1 ? "" : "s"}`; + } + return "< 1 sec"; +}; + +export const formatFileSize = (bytes: number | null | undefined) => { + if (bytes === null || bytes === undefined || !Number.isFinite(bytes)) { + return null; + } + if (bytes <= 0) return null; + if (bytes >= 1_000_000_000) return `${Math.round(bytes / 1_000_000_000)} GB`; + if (bytes >= 1_000_000) return `${Math.round(bytes / 1_000_000)} MB`; + if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`; + return `${Math.round(bytes)} B`; +}; From 96d8d585c34172b4994bd23b023a22588ceef025 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 08/20] feat(mobile): add shared UI components --- .../src/components/ActionButton.test.tsx | 147 ++++ apps/mobile/src/components/ActionButton.tsx | 320 +++++++++ apps/mobile/src/components/CapCard.test.ts | 521 ++++++++++++++ apps/mobile/src/components/CapCard.tsx | 649 ++++++++++++++++++ apps/mobile/src/components/CapLogoBadge.tsx | 32 + .../src/components/CapRefreshControl.test.tsx | 57 ++ .../src/components/CapRefreshControl.tsx | 22 + apps/mobile/src/components/GlassSurface.tsx | 72 ++ .../src/components/OrgSwitcher.test.tsx | 188 +++++ apps/mobile/src/components/OrgSwitcher.tsx | 246 +++++++ apps/mobile/src/components/Screen.test.tsx | 109 +++ apps/mobile/src/components/Screen.tsx | 124 ++++ .../mobile/src/components/capCardViewModel.ts | 60 ++ 13 files changed, 2547 insertions(+) create mode 100644 apps/mobile/src/components/ActionButton.test.tsx create mode 100644 apps/mobile/src/components/ActionButton.tsx create mode 100644 apps/mobile/src/components/CapCard.test.ts create mode 100644 apps/mobile/src/components/CapCard.tsx create mode 100644 apps/mobile/src/components/CapLogoBadge.tsx create mode 100644 apps/mobile/src/components/CapRefreshControl.test.tsx create mode 100644 apps/mobile/src/components/CapRefreshControl.tsx create mode 100644 apps/mobile/src/components/GlassSurface.tsx create mode 100644 apps/mobile/src/components/OrgSwitcher.test.tsx create mode 100644 apps/mobile/src/components/OrgSwitcher.tsx create mode 100644 apps/mobile/src/components/Screen.test.tsx create mode 100644 apps/mobile/src/components/Screen.tsx create mode 100644 apps/mobile/src/components/capCardViewModel.ts diff --git a/apps/mobile/src/components/ActionButton.test.tsx b/apps/mobile/src/components/ActionButton.test.tsx new file mode 100644 index 0000000000..c735962be4 --- /dev/null +++ b/apps/mobile/src/components/ActionButton.test.tsx @@ -0,0 +1,147 @@ +import type { ReactElement, ReactNode } from "react"; +import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { ActionButton } from "./ActionButton"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const resolveStyle = (style: unknown): Record => { + const resolved = + typeof style === "function" ? style({ pressed: false }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActivityIndicator: createHost("ActivityIndicator"), + Pressable: createHost("Pressable"), + StyleSheet: { + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +describe("ActionButton", () => { + it("matches the Cap web dark button surface and clips the inset highlight", async () => { + const renderer = await renderComponent( + , + ); + const button = renderer.root.findByProps({ + accessibilityLabel: "Upload", + }); + + expect(button.props.android_ripple).toEqual({ + color: "rgba(18, 22, 31, 0.05)", + }); + expect(button.props.hitSlop).toEqual({ + bottom: 4, + left: 4, + right: 4, + top: 4, + }); + expect(button.props.accessibilityState).toEqual({ + busy: false, + disabled: false, + }); + expect(button.props.accessibilityHint).toBe("Opens upload options"); + expect(resolveStyle(button.props.style)).toMatchObject({ + backgroundColor: "#202020", + borderColor: "#202020", + borderRadius: 999, + height: 44, + overflow: "hidden", + }); + }); + + it("uses the Cap web gray button token pair", async () => { + const renderer = await renderComponent( + , + ); + const button = renderer.root.findByProps({ + accessibilityLabel: "Photos", + }); + + expect(resolveStyle(button.props.style)).toMatchObject({ + backgroundColor: "#e0e0e0", + borderColor: "#bbbbbb", + }); + }); + + it("allows a specific native label while keeping short visible text", async () => { + const renderer = await renderComponent( + , + ); + const button = renderer.root.findByProps({ + accessibilityLabel: "Retry upload failed-upload.mp4", + }); + + expect(button.findByProps({ children: "Retry" }).props.children).toBe( + "Retry", + ); + expect(button.props.accessibilityValue).toEqual({ + text: "Upload failed", + }); + }); + + it("exposes native disabled and busy state while loading", async () => { + const renderer = await renderComponent( + , + ); + const button = renderer.root.findByProps({ + accessibilityLabel: "Upload", + }); + + expect(button.props.disabled).toBe(true); + expect(button.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + }); +}); diff --git a/apps/mobile/src/components/ActionButton.tsx b/apps/mobile/src/components/ActionButton.tsx new file mode 100644 index 0000000000..d12855f239 --- /dev/null +++ b/apps/mobile/src/components/ActionButton.tsx @@ -0,0 +1,320 @@ +import { type SFSymbol, SymbolView } from "expo-symbols"; +import type { ReactNode } from "react"; +import { + type AccessibilityValue, + ActivityIndicator, + type GestureResponderEvent, + Pressable, + StyleSheet, + Text, + View, + type ViewStyle, +} from "react-native"; +import { colors, fonts, radius, squircle } from "@/theme"; + +type ActionButtonProps = { + label: string; + onPress: (event?: GestureResponderEvent) => void; + accessibilityLabel?: string; + accessibilityHint?: string; + accessibilityValue?: AccessibilityValue; + symbol?: SFSymbol; + leading?: ReactNode; + variant?: + | "primary" + | "blue" + | "secondary" + | "gray" + | "dark" + | "danger" + | "ghost"; + size?: "sm" | "md" | "lg"; + disabled?: boolean; + loading?: boolean; + style?: ViewStyle; + children?: ReactNode; +}; + +const labelBySize = { + sm: "labelSm", + md: "labelMd", + lg: "labelLg", +} as const; + +const iconColor = ( + variant: NonNullable, + isDisabled: boolean, +) => { + if (isDisabled) { + if (variant === "primary") return colors.gray9; + if (variant === "blue" || variant === "dark" || variant === "danger") { + return colors.gray10; + } + if (variant === "gray") return colors.gray11; + } + + return variant === "primary" || + variant === "blue" || + variant === "dark" || + variant === "danger" + ? colors.white + : colors.gray12; +}; + +const usesInsetHighlight = ( + variant: NonNullable, +) => + variant === "primary" || + variant === "blue" || + variant === "gray" || + variant === "dark"; + +const buttonHitSlop = { bottom: 4, left: 4, right: 4, top: 4 }; +const androidRipple = { color: colors.blackAlpha5 }; + +export function ActionButton({ + label, + onPress, + accessibilityLabel, + accessibilityHint, + accessibilityValue, + symbol, + leading, + variant = "primary", + size = "md", + disabled = false, + loading = false, + style, + children, +}: ActionButtonProps) { + const isDisabled = disabled || loading; + const showInsetHighlight = usesInsetHighlight(variant); + + return ( + [ + styles.base, + styles[size], + styles[variant], + isDisabled && styles[`${variant}Disabled`], + pressed && !isDisabled && pressedStyles[variant], + style, + ]} + > + {showInsetHighlight ? ( + + ) : null} + {loading ? ( + + ) : leading ? ( + leading + ) : symbol ? ( + + ) : null} + + {children ?? label} + + + ); +} + +const styles = StyleSheet.create({ + base: { + alignItems: "center", + justifyContent: "center", + flexDirection: "row", + gap: 4, + position: "relative", + borderWidth: StyleSheet.hairlineWidth, + overflow: "hidden", + ...squircle, + }, + sm: { + height: 40, + borderRadius: radius.full, + paddingHorizontal: 20, + }, + md: { + height: 44, + borderRadius: radius.full, + paddingHorizontal: 20, + }, + lg: { + height: 48, + borderRadius: radius.full, + paddingHorizontal: 20, + }, + primary: { + backgroundColor: colors.gray12, + borderColor: colors.gray12, + }, + primaryPressed: { + backgroundColor: colors.gray11, + borderColor: colors.gray11, + }, + blue: { + backgroundColor: colors.buttonBlue, + borderColor: colors.buttonBlueBorder, + }, + bluePressed: { + backgroundColor: colors.buttonBlueHover, + borderColor: colors.buttonBlueBorder, + }, + dark: { + backgroundColor: colors.gray12, + borderColor: colors.gray12, + }, + darkPressed: { + backgroundColor: colors.gray11, + borderColor: colors.gray11, + }, + secondary: { + backgroundColor: colors.gray3, + borderColor: colors.gray5, + }, + secondaryPressed: { + backgroundColor: colors.gray5, + borderColor: colors.gray6, + }, + gray: { + backgroundColor: colors.gray5, + borderColor: colors.gray8, + }, + grayPressed: { + backgroundColor: colors.gray7, + borderColor: colors.gray8, + }, + danger: { + backgroundColor: colors.red9, + borderColor: colors.red9, + }, + dangerPressed: { + backgroundColor: colors.red10, + borderColor: colors.red10, + }, + ghost: { + backgroundColor: "transparent", + borderColor: "transparent", + }, + ghostPressed: { + backgroundColor: colors.blackAlpha5, + borderColor: "transparent", + }, + primaryDisabled: { + backgroundColor: colors.gray6, + borderColor: colors.gray6, + }, + blueDisabled: { + backgroundColor: colors.gray7, + borderColor: colors.gray8, + }, + darkDisabled: { + backgroundColor: colors.gray7, + borderColor: colors.gray8, + }, + secondaryDisabled: { + backgroundColor: colors.gray8, + borderColor: colors.gray8, + }, + grayDisabled: { + backgroundColor: colors.gray8, + borderColor: colors.gray7, + }, + dangerDisabled: { + backgroundColor: colors.gray7, + borderColor: colors.gray8, + }, + ghostDisabled: { + backgroundColor: "transparent", + borderColor: "transparent", + }, + label: { + fontFamily: fonts.medium, + }, + labelSm: { + fontSize: 14, + lineHeight: 18, + }, + labelMd: { + fontSize: 14, + lineHeight: 20, + }, + labelLg: { + fontSize: 16, + lineHeight: 22, + }, + primaryLabel: { + color: colors.white, + }, + defaultLabel: { + color: colors.gray12, + }, + primaryDisabledLabel: { + color: colors.gray9, + }, + blueDisabledLabel: { + color: colors.gray10, + }, + darkDisabledLabel: { + color: colors.gray10, + }, + secondaryDisabledLabel: { + color: colors.gray11, + }, + grayDisabledLabel: { + color: colors.gray11, + }, + dangerDisabledLabel: { + color: colors.gray10, + }, + ghostDisabledLabel: { + color: colors.gray9, + }, + insetHighlight: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: 1.5, + backgroundColor: "rgba(255, 255, 255, 0.4)", + }, +}); + +const pressedStyles = { + primary: styles.primaryPressed, + blue: styles.bluePressed, + secondary: styles.secondaryPressed, + gray: styles.grayPressed, + dark: styles.darkPressed, + danger: styles.dangerPressed, + ghost: styles.ghostPressed, +} as const; diff --git a/apps/mobile/src/components/CapCard.test.ts b/apps/mobile/src/components/CapCard.test.ts new file mode 100644 index 0000000000..fc441d203f --- /dev/null +++ b/apps/mobile/src/components/CapCard.test.ts @@ -0,0 +1,521 @@ +import { Video } from "@cap/web-domain"; +import React, { type ReactElement, type ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import type { MobileCapSummary } from "@/api/mobile"; +import { CapCard } from "./CapCard"; +import { getCapCardViewModel } from "./capCardViewModel"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderTree = async (node: ReactElement): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return (renderer as ReactTestRenderer | null)?.toJSON() ?? null; +}; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const hasProp = (node: JsonNode, prop: string, value: unknown): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) + return node.some((item) => hasProp(item, prop, value)); + if (node.props[prop] === value) return true; + return node.children?.some((child) => hasProp(child, prop, value)) ?? false; +}; + +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = typeof style === "function" ? style({ pressed }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActivityIndicator: createHost("ActivityIndicator"), + Pressable: createHost("Pressable"), + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: "absolute", + right: 0, + top: 0, + }, + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("expo-image", async () => { + const React = await import("react"); + return { + Image: (props: Record) => + React.createElement("Image", props), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +vi.mock("react-native-svg", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + default: createHost("Svg"), + Circle: createHost("Circle"), + }; +}); + +const cap: MobileCapSummary = { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: 125, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 7, + commentCount: 2, + reactionCount: 3, + upload: null, +}; + +describe("getCapCardViewModel", () => { + it("formats card rendering state", () => { + expect( + getCapCardViewModel(cap, new Date("2026-05-18T11:00:00.000Z")), + ).toMatchObject({ + date: "an hour ago", + duration: "2 mins", + visibility: "Shared", + accessibilityLabel: "Launch review, an hour ago, Shared", + }); + }); + + it("formats active upload state for the thumbnail overlay", () => { + expect( + getCapCardViewModel( + { + ...cap, + upload: { + uploaded: 25, + total: 100, + phase: "uploading", + processingProgress: 0, + processingMessage: null, + processingError: null, + }, + }, + new Date("2026-05-18T11:00:00.000Z"), + ), + ).toMatchObject({ + uploadStatusText: "25% uploaded", + uploadProgress: 25, + uploadFailed: false, + accessibilityLabel: "Launch review, an hour ago, Shared, 25% uploaded", + }); + }); + + it("keeps password protection separate from sharing state", () => { + expect( + getCapCardViewModel( + { + ...cap, + public: false, + protected: true, + }, + new Date("2026-05-18T11:00:00.000Z"), + ), + ).toMatchObject({ + date: "an hour ago", + visibility: "Not shared", + accessibilityLabel: "Launch review, an hour ago, Not shared", + }); + }); + + it("uses processing progress as a percent value", () => { + expect( + getCapCardViewModel({ + ...cap, + upload: { + uploaded: 100, + total: 100, + phase: "processing", + processingProgress: 42, + processingMessage: "Processing", + processingError: null, + }, + }).uploadProgress, + ).toBe(42); + }); + + it("keeps non-finite upload progress display-safe", () => { + const uploading = getCapCardViewModel( + { + ...cap, + upload: { + uploaded: Number.NaN, + total: Number.NaN, + phase: "uploading", + processingProgress: 0, + processingMessage: null, + processingError: null, + }, + }, + new Date("2026-05-18T11:00:00.000Z"), + ); + const processing = getCapCardViewModel({ + ...cap, + upload: { + uploaded: 100, + total: 100, + phase: "processing", + processingProgress: Number.POSITIVE_INFINITY, + processingMessage: "Processing", + processingError: null, + }, + }); + + expect(uploading).toMatchObject({ + uploadStatusText: "0% uploaded", + uploadProgress: 0, + accessibilityLabel: "Launch review, an hour ago, Shared, 0% uploaded", + }); + expect(processing.uploadProgress).toBe(0); + }); + + it("matches the web finishing state for completed processing records", () => { + expect( + getCapCardViewModel({ + ...cap, + upload: { + uploaded: 100, + total: 100, + phase: "complete", + processingProgress: 100, + processingMessage: null, + processingError: null, + }, + }).uploadStatusText, + ).toBe("Finishing up"); + }); +}); + +describe("CapCard", () => { + it("uses a branded thumbnail placeholder when a Cap has no thumbnail", async () => { + const tree = await renderTree( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + + expect(hasProp(tree, "fill", "#cecece")).toBe(true); + expect(hasProp(tree, "name", "play.fill")).toBe(false); + }); + + it("exposes active upload progress as a native progressbar", async () => { + const renderer = await renderComponent( + React.createElement(CapCard, { + cap: { + ...cap, + upload: { + uploaded: 25, + total: 100, + phase: "uploading", + processingProgress: 0, + processingMessage: null, + processingError: null, + }, + }, + onPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [progress] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload progress", + }); + if (!progress) throw new Error("Upload progress was not rendered"); + + expect(progress.props.accessibilityRole).toBe("progressbar"); + expect(progress.props.accessibilityValue).toEqual({ + max: 100, + min: 0, + now: 25, + text: "25%", + }); + }); + + it("exposes processing upload state as an indeterminate progressbar", async () => { + const renderer = await renderComponent( + React.createElement(CapCard, { + cap: { + ...cap, + upload: { + uploaded: 100, + total: 100, + phase: "processing", + processingProgress: 0, + processingMessage: "Processing", + processingError: null, + }, + }, + onPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [progress] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload progress", + }); + if (!progress) throw new Error("Upload progress was not rendered"); + + expect(progress.props.accessibilityRole).toBe("progressbar"); + expect(progress.props.accessibilityValue).toEqual({ + text: "Processing", + }); + }); + + it("shows copy, share, and more actions together", async () => { + const tree = await renderTree( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + onCopyPress: vi.fn(), + onSharePress: vi.fn(), + onMenuPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + + expect( + hasProp(tree, "accessibilityLabel", "Copy link for Launch review"), + ).toBe(true); + expect(hasProp(tree, "accessibilityHint", "Copies this Cap link")).toBe( + true, + ); + expect(hasProp(tree, "accessibilityLabel", "Share Launch review")).toBe( + true, + ); + expect( + hasProp(tree, "accessibilityHint", "Opens the native share sheet"), + ).toBe(true); + expect( + hasProp(tree, "accessibilityLabel", "More actions for Launch review"), + ).toBe(true); + expect(hasProp(tree, "accessibilityHint", "Opens Cap actions")).toBe(true); + }); + + it("uses the Cap web neutral button surface for card actions", async () => { + const renderer = await renderComponent( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + onCopyPress: vi.fn(), + onSharePress: vi.fn(), + onMenuPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [copyButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Copy link for Launch review", + }); + if (!copyButton) throw new Error("Copy action was not rendered"); + + expect(resolveStyle(copyButton.props.style)).toMatchObject({ + width: 32, + height: 32, + backgroundColor: "#f0f0f0", + borderColor: "#e0e0e0", + }); + expect(resolveStyle(copyButton.props.style, true)).toMatchObject({ + backgroundColor: "#e0e0e0", + borderColor: "#cecece", + }); + expect(copyButton.props.hitSlop).toEqual({ + bottom: 6, + left: 6, + right: 6, + top: 6, + }); + }); + + it("opens visibility controls from the shared status row like the web card", async () => { + const onVisibilityPress = vi.fn(); + const renderer = await renderComponent( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + onVisibilityPress, + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [shareState] = renderer.root.findAllByProps({ + accessibilityLabel: "Change sharing for Launch review", + }); + if (!shareState) throw new Error("Shared status action was not rendered"); + const stopPropagation = vi.fn(); + + expect(shareState.props.accessibilityHint).toBe("Opens sharing settings"); + expect(shareState.props.accessibilityState).toEqual({ + busy: false, + disabled: false, + }); + expect(shareState.props.hitSlop).toEqual({ + bottom: 6, + left: 6, + right: 6, + top: 6, + }); + + await act(async () => { + shareState.props.onPress({ stopPropagation }); + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(onVisibilityPress).toHaveBeenCalledTimes(1); + }); + + it("shows a disabled sharing state while the card visibility is updating", async () => { + const renderer = await renderComponent( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + onVisibilityPress: vi.fn(), + visibilityBusy: true, + visibilityDisabled: true, + visibilityDisabledHint: "Sharing update is in progress", + visibilityAccessibilityValue: "Updating sharing for Launch review", + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [shareState] = renderer.root.findAllByProps({ + accessibilityLabel: "Change sharing for Launch review", + }); + if (!shareState) throw new Error("Shared status action was not rendered"); + + expect(getTextNodes(renderer.toJSON())).toContain("Shared"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Updating..."); + expect(shareState.props.disabled).toBe(true); + expect(shareState.props.accessibilityHint).toBe( + "Sharing update is in progress", + ); + expect(shareState.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(shareState.props.accessibilityValue).toEqual({ + text: "Updating sharing for Launch review", + }); + expect(resolveStyle(shareState.props.style, true)).toMatchObject({ + backgroundColor: "#f9f9f9", + }); + }); + + it("opens analytics from the metrics row like the web card", async () => { + const onAnalyticsPress = vi.fn(); + const renderer = await renderComponent( + React.createElement(CapCard, { + cap, + onAnalyticsPress, + onPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [metricsRow] = renderer.root.findAllByProps({ + accessibilityLabel: "View analytics for Launch review", + }); + if (!metricsRow) throw new Error("Analytics action was not rendered"); + const stopPropagation = vi.fn(); + + expect(metricsRow.props.accessibilityHint).toBe( + "Opens analytics in a browser sheet", + ); + expect(metricsRow.props.accessibilityState).toEqual({ + disabled: false, + }); + + await act(async () => { + metricsRow.props.onPress({ stopPropagation }); + }); + + expect(stopPropagation).toHaveBeenCalled(); + expect(onAnalyticsPress).toHaveBeenCalledTimes(1); + }); + + it("marks metrics as disabled when analytics are informational only", async () => { + const renderer = await renderComponent( + React.createElement(CapCard, { + cap, + onPress: vi.fn(), + now: new Date("2026-05-18T11:00:00.000Z"), + }), + ); + const [metricsRow] = renderer.root.findAllByProps({ + accessibilityLabel: "View analytics for Launch review", + }); + if (!metricsRow) throw new Error("Metrics row was not rendered"); + + expect(metricsRow.props.disabled).toBe(true); + expect(metricsRow.props.accessibilityHint).toBeUndefined(); + expect(metricsRow.props.accessibilityState).toEqual({ + disabled: true, + }); + }); +}); diff --git a/apps/mobile/src/components/CapCard.tsx b/apps/mobile/src/components/CapCard.tsx new file mode 100644 index 0000000000..16b698c5b4 --- /dev/null +++ b/apps/mobile/src/components/CapCard.tsx @@ -0,0 +1,649 @@ +import { Image } from "expo-image"; +import { SymbolView } from "expo-symbols"; +import { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import Svg, { Circle } from "react-native-svg"; +import type { MobileCapSummary } from "@/api/mobile"; +import { colors, fonts, radius, squircle } from "@/theme"; +import { getCapCardViewModel } from "./capCardViewModel"; + +type CapCardProps = { + cap: MobileCapSummary; + onPress: () => void; + onCopyPress?: () => void; + onSharePress?: () => void; + onVisibilityPress?: () => void; + onAnalyticsPress?: () => void; + onMenuPress?: () => void; + visibilityBusy?: boolean; + visibilityDisabled?: boolean; + visibilityDisabledHint?: string; + visibilityValue?: string; + visibilityAccessibilityValue?: string; + now?: Date; +}; + +const progressSize = 18; +const progressStrokeWidth = 3; +const progressRadius = (progressSize - progressStrokeWidth) / 2; +const progressCircumference = 2 * Math.PI * progressRadius; +const compactHitSlop = { bottom: 6, left: 6, right: 6, top: 6 }; + +const getProgressAccessibilityValue = ( + progress: number | null, + indeterminate: boolean, + statusText: string, +) => { + if (indeterminate || progress === null) { + return { text: statusText }; + } + + const clampedProgress = Math.min(100, Math.max(0, progress)); + + return { + max: 100, + min: 0, + now: clampedProgress, + text: `${clampedProgress}%`, + }; +}; + +function CapThumbnailPlaceholder() { + return ( + + + + + + + + + + + ); +} + +function UploadProgressIndicator({ + progress, + indeterminate, + statusText, +}: { + progress: number | null; + indeterminate: boolean; + statusText: string; +}) { + const accessibilityValue = getProgressAccessibilityValue( + progress, + indeterminate, + statusText, + ); + + if (indeterminate || progress === null) { + return ( + + + + ); + } + + const strokeDashoffset = + progressCircumference - + (Math.min(100, Math.max(0, progress)) / 100) * progressCircumference; + + return ( + + + + + + + + + ); +} + +export function CapCard({ + cap, + onPress, + onCopyPress, + onSharePress, + onVisibilityPress, + onAnalyticsPress, + onMenuPress, + visibilityBusy = false, + visibilityDisabled = false, + visibilityDisabledHint, + visibilityValue, + visibilityAccessibilityValue, + now, +}: CapCardProps) { + const viewModel = getCapCardViewModel(cap, now); + const [copyPressed, setCopyPressed] = useState(false); + const copyResetTimer = useRef | null>(null); + const hasCopyAction = Boolean(onCopyPress); + const hasShareAction = Boolean(onSharePress); + const hasVisibleMenuAction = Boolean(onMenuPress); + const hasActions = hasCopyAction || hasShareAction || hasVisibleMenuAction; + const visibilityActionDisabled = visibilityDisabled || visibilityBusy; + const visibilityHint = visibilityActionDisabled + ? (visibilityDisabledHint ?? "Sharing update is in progress") + : "Opens sharing settings"; + const visibilityText = visibilityValue ?? viewModel.visibility; + const uploadIndeterminate = + Boolean(cap.upload) && + cap.upload?.phase !== "uploading" && + (viewModel.uploadProgress ?? 0) === 0; + + useEffect( + () => () => { + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + }, + [], + ); + + const copyLink = () => { + if (!onCopyPress) return; + onCopyPress(); + setCopyPressed(true); + if (copyResetTimer.current) clearTimeout(copyResetTimer.current); + copyResetTimer.current = setTimeout(() => { + setCopyPressed(false); + copyResetTimer.current = null; + }, 1400); + }; + + return ( + [styles.card, pressed && styles.pressed]} + > + + {hasActions ? ( + + {onCopyPress ? ( + { + event.stopPropagation(); + copyLink(); + }} + style={({ pressed }) => [ + styles.actionIconButton, + pressed && styles.actionIconButtonPressed, + ]} + > + + + ) : null} + {onSharePress ? ( + { + event.stopPropagation(); + onSharePress(); + }} + style={({ pressed }) => [ + styles.actionIconButton, + pressed && styles.actionIconButtonPressed, + ]} + > + + + ) : null} + {onMenuPress ? ( + { + event.stopPropagation(); + onMenuPress(); + }} + style={({ pressed }) => [ + styles.actionIconButton, + pressed && styles.actionIconButtonPressed, + ]} + > + + + ) : null} + + ) : null} + {cap.thumbnailUrl ? ( + + ) : ( + + )} + {viewModel.uploadStatusText ? ( + + + + {viewModel.uploadStatusText} + + {viewModel.uploadFailed ? null : ( + + )} + + + ) : null} + {cap.protected ? ( + + + + ) : null} + {viewModel.duration ? ( + + {viewModel.duration} + + ) : null} + + + + + {cap.title} + + {onVisibilityPress ? ( + { + event.stopPropagation(); + onVisibilityPress(); + }} + style={({ pressed }) => [ + styles.shareStateButton, + pressed && + !visibilityActionDisabled && + styles.shareStateButtonPressed, + visibilityActionDisabled && styles.shareStateButtonDisabled, + ]} + > + + {visibilityText} + + + + ) : ( + + {viewModel.visibility} + + )} + + {viewModel.date} + + + { + event.stopPropagation(); + onAnalyticsPress?.(); + }} + style={({ pressed }) => [ + styles.metricsRow, + onAnalyticsPress && styles.metricsRowAction, + pressed && onAnalyticsPress && styles.metricsRowPressed, + ]} + > + + + {cap.viewCount} + + + + {cap.commentCount} + + + + {cap.reactionCount} + + {onAnalyticsPress ? ( + View analytics + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.gray1, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + overflow: "hidden", + marginBottom: 16, + ...squircle, + }, + pressed: { + backgroundColor: colors.gray2, + borderColor: colors.blue10, + }, + thumbnailWrap: { + width: "100%", + aspectRatio: 16 / 9, + backgroundColor: colors.black, + position: "relative", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.gray3, + }, + thumbnail: { + width: "100%", + height: "100%", + }, + emptyThumbnail: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + overflow: "hidden", + }, + placeholderSheen: { + position: "absolute", + top: -36, + left: -28, + width: "78%", + height: "140%", + backgroundColor: "rgba(255, 255, 255, 0.34)", + transform: [{ rotate: "18deg" }], + }, + placeholderMark: { + width: 48, + height: 48, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.72)", + borderWidth: StyleSheet.hairlineWidth, + borderColor: "rgba(255, 255, 255, 0.95)", + ...squircle, + }, + durationPill: { + position: "absolute", + left: 12, + bottom: 12, + minWidth: 46, + height: 23, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.5)", + paddingHorizontal: 8, + ...squircle, + }, + durationText: { + fontFamily: fonts.medium, + fontSize: 11, + color: colors.white, + }, + lockBadge: { + position: "absolute", + right: 10, + top: 10, + zIndex: 2, + width: 28, + height: 28, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.7)", + }, + lockBadgeWithActions: { + right: 46, + }, + actionStack: { + position: "absolute", + right: 10, + top: 10, + zIndex: 2, + gap: 8, + }, + actionIconButton: { + width: 32, + height: 32, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray5, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 2, + ...squircle, + }, + actionIconButtonPressed: { + backgroundColor: colors.gray5, + borderColor: colors.gray7, + }, + uploadOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: "flex-end", + backgroundColor: "rgba(0, 0, 0, 0.58)", + paddingHorizontal: 12, + paddingBottom: 12, + zIndex: 1, + }, + uploadStatusRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingRight: 96, + }, + uploadStatusText: { + fontFamily: fonts.medium, + fontSize: 14, + lineHeight: 19, + color: colors.white, + }, + progressIndicator: { + width: progressSize, + height: progressSize, + alignItems: "center", + justifyContent: "center", + }, + progressRing: { + transform: [{ rotate: "-90deg" }], + }, + body: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 12, + }, + title: { + fontFamily: fonts.medium, + fontSize: 16, + lineHeight: 21, + color: colors.gray12, + marginTop: 13, + marginBottom: 4, + }, + shareState: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 19, + color: colors.gray10, + }, + shareStateButton: { + alignSelf: "flex-start", + minHeight: 22, + maxWidth: "100%", + flexDirection: "row", + alignItems: "center", + gap: 5, + marginBottom: 2, + borderRadius: radius.xs, + paddingHorizontal: 3, + marginLeft: -3, + ...squircle, + }, + shareStateButtonPressed: { + backgroundColor: colors.gray3, + }, + shareStateButtonDisabled: { + backgroundColor: colors.gray2, + }, + shareStateDisabled: { + color: colors.gray8, + }, + meta: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + }, + metricsRow: { + flexDirection: "row", + alignItems: "center", + gap: 16, + minHeight: 24, + }, + metricsRowAction: { + width: "100%", + maxWidth: "100%", + borderRadius: radius.xs, + paddingHorizontal: 3, + marginLeft: -3, + ...squircle, + }, + metricsRowPressed: { + backgroundColor: colors.gray3, + }, + metric: { + flexDirection: "row", + alignItems: "center", + gap: 7, + }, + metricText: { + fontFamily: fonts.regular, + fontSize: 14, + color: colors.gray12, + }, + analyticsLink: { + marginLeft: "auto", + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 17, + color: colors.blue11, + }, +}); diff --git a/apps/mobile/src/components/CapLogoBadge.tsx b/apps/mobile/src/components/CapLogoBadge.tsx new file mode 100644 index 0000000000..7393a5b2e3 --- /dev/null +++ b/apps/mobile/src/components/CapLogoBadge.tsx @@ -0,0 +1,32 @@ +import Svg, { Path, Rect } from "react-native-svg"; +import { colors } from "@/theme"; + +type CapLogoBadgeProps = { + size?: number; +}; + +export function CapLogoBadge({ size = 48 }: CapLogoBadgeProps) { + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/components/CapRefreshControl.test.tsx b/apps/mobile/src/components/CapRefreshControl.test.tsx new file mode 100644 index 0000000000..372a9adf58 --- /dev/null +++ b/apps/mobile/src/components/CapRefreshControl.test.tsx @@ -0,0 +1,57 @@ +import type { ReactElement, ReactNode } from "react"; +import { RefreshControl } from "react-native"; +import TestRenderer, { act, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { CapRefreshControl } from "./CapRefreshControl"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + RefreshControl: createHost("RefreshControl"), + StyleSheet: { + create: >(styles: T) => styles, + }, + }; +}); + +describe("CapRefreshControl", () => { + it("uses Cap web colors for native pull-to-refresh", async () => { + const onRefresh = vi.fn(); + const renderer = await renderComponent( + , + ); + const refreshControl = renderer.root.findByType(RefreshControl); + + expect(refreshControl.props).toMatchObject({ + colors: ["#0d74ce"], + onRefresh, + progressBackgroundColor: "#fcfcfc", + refreshing: true, + tintColor: "#0d74ce", + }); + }); +}); diff --git a/apps/mobile/src/components/CapRefreshControl.tsx b/apps/mobile/src/components/CapRefreshControl.tsx new file mode 100644 index 0000000000..6c55078bc4 --- /dev/null +++ b/apps/mobile/src/components/CapRefreshControl.tsx @@ -0,0 +1,22 @@ +import { RefreshControl } from "react-native"; +import { colors } from "@/theme"; + +type CapRefreshControlProps = { + refreshing: boolean; + onRefresh: () => void; +}; + +export function CapRefreshControl({ + refreshing, + onRefresh, +}: CapRefreshControlProps) { + return ( + + ); +} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx new file mode 100644 index 0000000000..9ee85941ac --- /dev/null +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -0,0 +1,72 @@ +import { + GlassView, + isGlassEffectAPIAvailable, + isLiquidGlassAvailable, +} from "expo-glass-effect"; +import type { ReactNode } from "react"; +import { + Platform, + type StyleProp, + StyleSheet, + View, + type ViewStyle, +} from "react-native"; +import { colors } from "@/theme"; + +type GlassSurfaceProps = { + children?: ReactNode; + style?: StyleProp; + fallbackStyle?: StyleProp; + glassEffectStyle?: "clear" | "regular" | "none"; + tintColor?: string; + isInteractive?: boolean; +}; + +const getGlassAvailable = () => { + if (Platform.OS !== "ios") return false; + try { + return isGlassEffectAPIAvailable() && isLiquidGlassAvailable(); + } catch { + return false; + } +}; + +const glassAvailable = getGlassAvailable(); + +export function GlassSurface({ + children, + style, + fallbackStyle, + glassEffectStyle = "regular", + tintColor = colors.glass, + isInteractive = false, +}: GlassSurfaceProps) { + if (glassAvailable) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + surface: { + overflow: "hidden", + }, + fallback: { + backgroundColor: colors.glass, + }, +}); diff --git a/apps/mobile/src/components/OrgSwitcher.test.tsx b/apps/mobile/src/components/OrgSwitcher.test.tsx new file mode 100644 index 0000000000..57f01ea78c --- /dev/null +++ b/apps/mobile/src/components/OrgSwitcher.test.tsx @@ -0,0 +1,188 @@ +import { Organisation, User } from "@cap/web-domain"; +import type { ReactElement, ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import type { MobileBootstrapResponse } from "@/api/mobile"; +import { OrgSwitcher } from "./OrgSwitcher"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderTree = async (node: ReactElement): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return (renderer as ReactTestRenderer | null)?.toJSON() ?? null; +}; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const hasImageSourceUri = (node: JsonNode, uri: string): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) + return node.some((item) => hasImageSourceUri(item, uri)); + const source = node.props.source; + if ( + source && + typeof source === "object" && + "uri" in source && + source.uri === uri + ) { + return true; + } + return node.children?.some((child) => hasImageSourceUri(child, uri)) ?? false; +}; + +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = typeof style === "function" ? style({ pressed }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActionSheetIOS: { + showActionSheetWithOptions: vi.fn(), + }, + Modal: createHost("Modal"), + Platform: { + OS: "ios", + }, + Pressable: createHost("Pressable"), + StyleSheet: { + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("expo-image", async () => { + const React = await import("react"); + return { + Image: (props: Record) => + React.createElement("Image", props), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +const bootstrap: MobileBootstrapResponse = { + user: { + id: User.UserId.make("user_123"), + name: "Richie", + email: "richie@cap.so", + imageUrl: null, + activeOrganizationId: Organisation.OrganisationId.make("org_123"), + }, + organizations: [ + { + id: Organisation.OrganisationId.make("org_123"), + name: "Cap", + iconUrl: "https://cap.so/icon.png", + role: "owner", + }, + { + id: Organisation.OrganisationId.make("org_456"), + name: "Design", + iconUrl: null, + role: "member", + }, + ], + activeOrganizationId: Organisation.OrganisationId.make("org_123"), + rootFolders: [], +}; + +describe("OrgSwitcher", () => { + it("uses the organization icon when the active org has one", async () => { + const tree = await renderTree( + , + ); + + expect(hasImageSourceUri(tree, "https://cap.so/icon.png")).toBe(true); + }); + + it("uses a native organization action sheet with roles and disabled active org", async () => { + const onChange = vi.fn(() => Promise.resolve()); + const renderer = await renderComponent( + , + ); + const [trigger] = renderer.root.findAllByProps({ + accessibilityLabel: "Switch organization", + }); + if (!trigger) throw new Error("Organization switcher was not rendered"); + + expect(resolveStyle(trigger.props.style, true)).toMatchObject({ + backgroundColor: "#f0f0f0", + borderColor: "#d9d9d9", + }); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + trigger.props.onPress(); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 2, + disabledButtonIndices: [0], + disabledButtonTintColor: "#8d8d8d", + options: ["Cap (Owner)", "Design (Member)", "Cancel"], + title: "Organization", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + + const [, callback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!callback) throw new Error("Organization action sheet did not open"); + + await act(async () => { + callback(1); + }); + + expect(onChange).toHaveBeenCalledWith("org_456"); + }); +}); diff --git a/apps/mobile/src/components/OrgSwitcher.tsx b/apps/mobile/src/components/OrgSwitcher.tsx new file mode 100644 index 0000000000..b87f5391fa --- /dev/null +++ b/apps/mobile/src/components/OrgSwitcher.tsx @@ -0,0 +1,246 @@ +import { Image } from "expo-image"; +import { SymbolView } from "expo-symbols"; +import { useState } from "react"; +import { + ActionSheetIOS, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import type { MobileBootstrapResponse } from "@/api/mobile"; +import { colors, fonts, radius, shadows, squircle } from "@/theme"; + +type OrgSwitcherProps = { + bootstrap: MobileBootstrapResponse; + onChange: (organizationId: string) => Promise; +}; + +type Organization = MobileBootstrapResponse["organizations"][number]; + +const formatRole = (role: Organization["role"]) => + role.slice(0, 1).toUpperCase() + role.slice(1); + +function OrgAvatar({ organization }: { organization: Organization }) { + return ( + + {organization.iconUrl ? ( + + ) : ( + + {organization.name.slice(0, 1).toUpperCase()} + + )} + + ); +} + +export function OrgSwitcher({ bootstrap, onChange }: OrgSwitcherProps) { + const [open, setOpen] = useState(false); + const activeOrganization = + bootstrap.organizations.find( + (org) => org.id === bootstrap.activeOrganizationId, + ) ?? bootstrap.organizations[0]; + + if (!activeOrganization) return null; + + const openSwitcher = () => { + if (Platform.OS === "ios") { + const activeIndex = bootstrap.organizations.findIndex( + (organization) => organization.id === activeOrganization.id, + ); + const options = [ + ...bootstrap.organizations.map( + (organization) => + `${organization.name} (${formatRole(organization.role)})`, + ), + "Cancel", + ]; + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: options.length - 1, + disabledButtonIndices: activeIndex >= 0 ? [activeIndex] : undefined, + disabledButtonTintColor: colors.gray9, + message: activeOrganization.name, + options, + title: "Organization", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + const organization = bootstrap.organizations[index]; + if (organization && organization.id !== activeOrganization.id) { + void onChange(organization.id); + } + }, + ); + return; + } + setOpen(true); + }; + + return ( + <> + [ + styles.trigger, + pressed && styles.triggerPressed, + ]} + > + + + {activeOrganization.name} + + + + setOpen(false)} + presentationStyle="overFullScreen" + transparent + visible={open} + > + setOpen(false)}> + + Organization + {bootstrap.organizations.map((org) => { + const active = org.id === activeOrganization.id; + return ( + { + setOpen(false); + if (!active) await onChange(org.id); + }} + style={styles.orgRow} + > + + + + {org.name} + + {org.role} + + {active ? ( + + ) : null} + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + trigger: { + height: 44, + flexDirection: "row", + alignItems: "center", + gap: 9, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray5, + backgroundColor: colors.gray1, + borderRadius: radius.full, + paddingHorizontal: 10, + ...squircle, + }, + triggerPressed: { + backgroundColor: colors.gray3, + borderColor: colors.gray6, + }, + triggerText: { + flex: 1, + fontFamily: fonts.medium, + color: colors.gray12, + fontSize: 15, + }, + avatar: { + width: 26, + height: 26, + borderRadius: radius.xs, + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.blue3, + ...squircle, + }, + avatarImage: { + width: "100%", + height: "100%", + }, + avatarText: { + fontFamily: fonts.medium, + fontSize: 12, + color: colors.blue11, + }, + overlay: { + flex: 1, + backgroundColor: colors.blackAlpha40, + justifyContent: "flex-end", + }, + sheet: { + backgroundColor: colors.gray1, + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + padding: 18, + paddingBottom: 32, + gap: 6, + ...shadows.popover, + ...squircle, + }, + sheetTitle: { + fontFamily: fonts.medium, + fontSize: 20, + lineHeight: 26, + color: colors.gray12, + marginBottom: 6, + }, + orgRow: { + minHeight: 58, + flexDirection: "row", + alignItems: "center", + gap: 12, + borderRadius: radius.sm, + paddingHorizontal: 8, + ...squircle, + }, + orgTextWrap: { + flex: 1, + minWidth: 0, + }, + orgName: { + fontFamily: fonts.medium, + fontSize: 16, + color: colors.gray12, + }, + orgRole: { + fontFamily: fonts.regular, + fontSize: 12, + color: colors.gray10, + textTransform: "capitalize", + }, +}); diff --git a/apps/mobile/src/components/Screen.test.tsx b/apps/mobile/src/components/Screen.test.tsx new file mode 100644 index 0000000000..0e5fe24588 --- /dev/null +++ b/apps/mobile/src/components/Screen.test.tsx @@ -0,0 +1,109 @@ +import type React from "react"; +import TestRenderer, { + act, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { Screen } from "./Screen"; + +type HostProps = { + children?: React.ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderTree = async (): Promise => { + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + , + ); + }); + return (renderer as TestRenderer.ReactTestRenderer | null)?.toJSON() ?? null; +}; + +const findTextByValue = ( + node: JsonNode, + value: string, +): ReactTestRendererJSON | null => { + if (!node || typeof node === "string") return null; + if (Array.isArray(node)) { + for (const item of node) { + const match = findTextByValue(item, value); + if (match) return match; + } + return null; + } + if (node.type === "Text" && node.children?.includes(value)) return node; + for (const child of node.children ?? []) { + const match = findTextByValue(child, value); + if (match) return match; + } + return null; +}; + +const hasStyle = ( + node: JsonNode, + expected: Record, +): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) return node.some((item) => hasStyle(item, expected)); + const resolved = Array.isArray(node.props.style) + ? Object.assign({}, ...node.props.style.filter(Boolean)) + : node.props.style; + if ( + resolved && + Object.entries(expected).every(([key, value]) => resolved[key] === value) + ) { + return true; + } + return node.children?.some((child) => hasStyle(child, expected)) ?? false; +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActivityIndicator: createHost("ActivityIndicator"), + RefreshControl: createHost("RefreshControl"), + ScrollView: createHost("ScrollView"), + StyleSheet: { + create: >(styles: T) => styles, + }, + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("react-native-safe-area-context", async () => { + const React = await import("react"); + return { + SafeAreaView: ({ children, ...props }: HostProps) => + React.createElement("SafeAreaView", props, children), + }; +}); + +describe("Screen", () => { + it("uses the Cap web subtitle scale", async () => { + const tree = await renderTree(); + const subtitle = findTextByValue( + tree, + "Import videos from external sources.", + ); + + expect(subtitle?.props.style).toMatchObject({ + fontSize: 14, + lineHeight: 20, + }); + expect(hasStyle(tree, { paddingBottom: 32 })).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/Screen.tsx b/apps/mobile/src/components/Screen.tsx new file mode 100644 index 0000000000..2e2e456e8b --- /dev/null +++ b/apps/mobile/src/components/Screen.tsx @@ -0,0 +1,124 @@ +import type { ReactNode } from "react"; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { type Edge, SafeAreaView } from "react-native-safe-area-context"; +import { colors, fonts } from "@/theme"; +import { CapRefreshControl } from "./CapRefreshControl"; + +type ScreenProps = { + children?: ReactNode; + title?: string; + subtitle?: string | null; + scroll?: boolean; + refreshing?: boolean; + onRefresh?: () => void; + loading?: boolean; + footer?: ReactNode; + safeEdges?: Edge[]; +}; + +const defaultSafeEdges: Edge[] = ["top", "left", "right"]; + +export function Screen({ + children, + title, + subtitle, + scroll = false, + refreshing = false, + onRefresh, + loading = false, + footer, + safeEdges = defaultSafeEdges, +}: ScreenProps) { + const content = ( + <> + {title ? ( + + {title} + {subtitle ? {subtitle} : null} + + ) : null} + {loading ? ( + + + + ) : ( + children + )} + {footer} + + ); + + return ( + + {scroll ? ( + + ) : undefined + } + > + {content} + + ) : ( + {content} + )} + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: colors.appBackground, + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingBottom: 18, + }, + scrollContent: { + flexGrow: 1, + paddingHorizontal: 20, + paddingBottom: 28, + }, + header: { + paddingTop: 8, + paddingBottom: 16, + gap: 4, + }, + headerWithSubtitle: { + paddingBottom: 32, + }, + title: { + fontFamily: fonts.medium, + fontSize: 24, + lineHeight: 30, + color: colors.gray12, + }, + subtitle: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + }, + loading: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingVertical: 48, + }, +}); diff --git a/apps/mobile/src/components/capCardViewModel.ts b/apps/mobile/src/components/capCardViewModel.ts new file mode 100644 index 0000000000..84aae8189f --- /dev/null +++ b/apps/mobile/src/components/capCardViewModel.ts @@ -0,0 +1,60 @@ +import type { MobileCapSummary } from "@/api/mobile"; +import { formatDuration, formatRelativeDate } from "../utils/format"; + +const clampPercent = (value: number) => { + const safeValue = Number.isFinite(value) ? value : 0; + return Math.min(100, Math.max(0, Math.round(safeValue))); +}; + +const getUploadProgress = (cap: MobileCapSummary) => { + if (!cap.upload) return null; + + if (cap.upload.phase === "uploading") { + return clampPercent( + (cap.upload.total > 0 ? cap.upload.uploaded / cap.upload.total : 0) * 100, + ); + } + + return clampPercent(cap.upload.processingProgress); +}; + +const getUploadStatusText = (cap: MobileCapSummary) => { + if (!cap.upload) return null; + + switch (cap.upload.phase) { + case "processing": + return cap.upload.processingMessage ?? "Processing"; + case "generating_thumbnail": + return cap.upload.processingMessage ?? "Finishing up"; + case "complete": + return cap.upload.processingMessage ?? "Finishing up"; + case "error": + return cap.upload.processingError ?? "Upload failed"; + default: + return `${getUploadProgress(cap) ?? 0}% uploaded`; + } +}; + +export const getCapCardViewModel = ( + cap: MobileCapSummary, + now = new Date(), +) => { + const duration = formatDuration(cap.durationSeconds); + const date = formatRelativeDate(cap.createdAt, now); + const visibility = cap.public ? "Shared" : "Not shared"; + const uploadStatusText = getUploadStatusText(cap); + const uploadProgress = getUploadProgress(cap); + const uploadFailed = cap.upload?.phase === "error"; + + return { + date, + duration, + visibility, + uploadStatusText, + uploadProgress, + uploadFailed, + accessibilityLabel: [cap.title, date, visibility, uploadStatusText] + .filter(Boolean) + .join(", "), + }; +}; From 92c782304fc3fd0d8bc2f49980cd068cb92f728f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 09/20] feat(mobile): add auth session and sign-in flow --- apps/mobile/src/auth/AuthContext.test.ts | 31 + apps/mobile/src/auth/AuthContext.tsx | 256 ++++ apps/mobile/src/auth/AuthProvider.test.tsx | 172 +++ apps/mobile/src/auth/SignInPanel.test.tsx | 1234 +++++++++++++++++ apps/mobile/src/auth/SignInPanel.tsx | 1053 ++++++++++++++ apps/mobile/src/auth/session.ts | 20 + .../mobile/src/auth/signInDestination.test.ts | 12 + apps/mobile/src/auth/signInDestination.ts | 5 + 8 files changed, 2783 insertions(+) create mode 100644 apps/mobile/src/auth/AuthContext.test.ts create mode 100644 apps/mobile/src/auth/AuthContext.tsx create mode 100644 apps/mobile/src/auth/AuthProvider.test.tsx create mode 100644 apps/mobile/src/auth/SignInPanel.test.tsx create mode 100644 apps/mobile/src/auth/SignInPanel.tsx create mode 100644 apps/mobile/src/auth/session.ts create mode 100644 apps/mobile/src/auth/signInDestination.test.ts create mode 100644 apps/mobile/src/auth/signInDestination.ts diff --git a/apps/mobile/src/auth/AuthContext.test.ts b/apps/mobile/src/auth/AuthContext.test.ts new file mode 100644 index 0000000000..e0bc13805f --- /dev/null +++ b/apps/mobile/src/auth/AuthContext.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { parseAuthRedirect, requireAuthRedirectSession } from "./session"; + +describe("parseAuthRedirect", () => { + it("extracts the issued API key and user id", () => { + expect( + parseAuthRedirect("cap://auth?api_key=key_123&user_id=user_123"), + ).toEqual({ + apiKey: "key_123", + userId: "user_123", + }); + }); + + it("rejects redirects without an API key", () => { + expect(parseAuthRedirect("cap://auth?user_id=user_123")).toBeNull(); + }); + + it("throws a usable message for failed auth callbacks", () => { + expect(() => + requireAuthRedirectSession( + "cap://auth?error_description=Organization%20not%20found", + ), + ).toThrow("Organization not found"); + }); + + it("throws when an auth callback omits the mobile API key", () => { + expect(() => + requireAuthRedirectSession("cap://auth?user_id=user_123"), + ).toThrow("Sign in did not return a mobile session."); + }); +}); diff --git a/apps/mobile/src/auth/AuthContext.tsx b/apps/mobile/src/auth/AuthContext.tsx new file mode 100644 index 0000000000..9c97d7ec2e --- /dev/null +++ b/apps/mobile/src/auth/AuthContext.tsx @@ -0,0 +1,256 @@ +import Constants from "expo-constants"; +import * as Linking from "expo-linking"; +import * as SecureStore from "expo-secure-store"; +import * as WebBrowser from "expo-web-browser"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + createMobileApiClient, + createSessionRequestUrl, + type MobileApiClient, + type MobileAuthConfigResponse, + type MobileBootstrapResponse, +} from "@/api/mobile"; +import { requireAuthRedirectSession } from "./session"; + +WebBrowser.maybeCompleteAuthSession(); + +const sessionKey = "cap.mobile.apiKey"; +const userIdKey = "cap.mobile.userId"; + +type AuthState = { + status: "loading" | "signedOut" | "signedIn"; + apiKey: string | null; + userId: string | null; + authConfig: MobileAuthConfigResponse; + bootstrap: MobileBootstrapResponse | null; + client: MobileApiClient; + requestEmailCode: (email: string) => Promise; + verifyEmailCode: (email: string, code: string) => Promise; + signInWithGoogle: () => Promise; + signInWithSso: (organizationId: string) => Promise; + signOut: () => Promise; + refresh: () => Promise; + setActiveOrganization: (organizationId: string) => Promise; +}; + +const AuthContext = createContext(null); +const fallbackAuthConfig: MobileAuthConfigResponse = { + googleAuthAvailable: true, + workosAuthAvailable: true, +}; + +const getExtraString = (key: string, fallback: string) => { + const extra = Constants.expoConfig?.extra; + if (!extra || typeof extra !== "object") return fallback; + const value = (extra as Record)[key]; + return typeof value === "string" ? value : fallback; +}; + +export const apiBaseUrl = getExtraString("apiBaseUrl", "https://cap.so"); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [apiKey, setApiKey] = useState(null); + const [userId, setUserId] = useState(null); + const [authConfig, setAuthConfig] = + useState(fallbackAuthConfig); + const [bootstrap, setBootstrap] = useState( + null, + ); + const [loading, setLoading] = useState(true); + + const client = useMemo( + () => + createMobileApiClient({ + baseUrl: apiBaseUrl, + getToken: () => apiKey, + }), + [apiKey], + ); + const publicClient = useMemo( + () => + createMobileApiClient({ + baseUrl: apiBaseUrl, + getToken: () => null, + }), + [], + ); + + const refresh = useCallback(async () => { + const response = await client.bootstrap(); + setBootstrap(response); + }, [client]); + + useEffect(() => { + let active = true; + const load = async () => { + try { + const [storedKey, storedUserId, nextAuthConfig] = await Promise.all([ + SecureStore.getItemAsync(sessionKey), + SecureStore.getItemAsync(userIdKey), + publicClient.getAuthConfig().catch(() => fallbackAuthConfig), + ]); + if (!active) return; + setApiKey(storedKey); + setUserId(storedKey ? storedUserId : null); + setAuthConfig(nextAuthConfig); + if (!storedKey && storedUserId) { + SecureStore.deleteItemAsync(userIdKey).catch(() => {}); + } + } finally { + if (active) setLoading(false); + } + }; + load(); + return () => { + active = false; + }; + }, [publicClient]); + + useEffect(() => { + if (!apiKey) { + setUserId(null); + setBootstrap(null); + return; + } + + refresh().catch(() => { + setApiKey(null); + setUserId(null); + setBootstrap(null); + SecureStore.deleteItemAsync(sessionKey).catch(() => {}); + SecureStore.deleteItemAsync(userIdKey).catch(() => {}); + }); + }, [apiKey, refresh]); + + const storeSession = useCallback( + async (session: { apiKey: string; userId: string | null }) => { + await SecureStore.setItemAsync(sessionKey, session.apiKey); + if (session.userId) { + await SecureStore.setItemAsync(userIdKey, session.userId); + } else { + await SecureStore.deleteItemAsync(userIdKey); + } + setApiKey(session.apiKey); + setUserId(session.userId); + }, + [], + ); + + const requestEmailCode = useCallback( + async (email: string) => { + await client.requestEmailCode(email); + }, + [client], + ); + + const verifyEmailCode = useCallback( + async (email: string, code: string) => { + const session = await client.verifyEmailCode({ email, code }); + await storeSession({ + apiKey: session.apiKey, + userId: session.userId, + }); + }, + [client, storeSession], + ); + + const signInWithGoogle = useCallback(async () => { + const redirectUri = Linking.createURL("auth"); + const result = await WebBrowser.openAuthSessionAsync( + createSessionRequestUrl(apiBaseUrl, redirectUri, "google"), + redirectUri, + ); + if (result.type !== "success") return; + + await storeSession(requireAuthRedirectSession(result.url)); + }, [storeSession]); + + const signInWithSso = useCallback( + async (organizationId: string) => { + const redirectUri = Linking.createURL("auth"); + const result = await WebBrowser.openAuthSessionAsync( + createSessionRequestUrl( + apiBaseUrl, + redirectUri, + "workos", + organizationId, + ), + redirectUri, + ); + if (result.type !== "success") return; + + await storeSession(requireAuthRedirectSession(result.url)); + }, + [storeSession], + ); + + const signOut = useCallback(async () => { + if (apiKey) { + await client.revokeSession().catch(() => {}); + } + await Promise.all([ + SecureStore.deleteItemAsync(sessionKey), + SecureStore.deleteItemAsync(userIdKey), + ]); + setApiKey(null); + setUserId(null); + setBootstrap(null); + }, [apiKey, client]); + + const setActiveOrganization = useCallback( + async (organizationId: string) => { + const nextBootstrap = await client.setActiveOrganization(organizationId); + setBootstrap(nextBootstrap); + }, + [client], + ); + + const value = useMemo( + () => ({ + status: loading ? "loading" : apiKey ? "signedIn" : "signedOut", + apiKey, + userId, + authConfig, + bootstrap, + client, + requestEmailCode, + verifyEmailCode, + signInWithGoogle, + signInWithSso, + signOut, + refresh, + setActiveOrganization, + }), + [ + loading, + apiKey, + userId, + authConfig, + bootstrap, + client, + requestEmailCode, + verifyEmailCode, + signInWithGoogle, + signInWithSso, + signOut, + refresh, + setActiveOrganization, + ], + ); + + return {children}; +} + +export const useAuth = () => { + const value = useContext(AuthContext); + if (!value) throw new Error("useAuth must be used inside AuthProvider"); + return value; +}; diff --git a/apps/mobile/src/auth/AuthProvider.test.tsx b/apps/mobile/src/auth/AuthProvider.test.tsx new file mode 100644 index 0000000000..0d9c232f2b --- /dev/null +++ b/apps/mobile/src/auth/AuthProvider.test.tsx @@ -0,0 +1,172 @@ +import React, { type ReactNode } from "react"; +import TestRenderer, { act } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider, useAuth } from "./AuthContext"; + +type HostProps = { + children?: ReactNode; +}; + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn((_key: string) => Promise.resolve()), + getItemAsync: vi.fn((_key: string) => Promise.resolve(null as string | null)), + setItemAsync: vi.fn((_key: string, _value: string) => Promise.resolve()), +})); + +const apiMock = vi.hoisted(() => ({ + bootstrap: vi.fn(() => + Promise.resolve({ + activeOrganizationId: "org_123", + user: { + email: "richie@cap.so", + name: "Richie", + }, + }), + ), + getAuthConfig: vi.fn(() => + Promise.resolve({ + googleAuthAvailable: true, + workosAuthAvailable: true, + }), + ), +})); + +vi.mock("react-native", async () => { + const React = await import("react"); + + return { + View: ({ children }: HostProps) => + React.createElement("View", null, children), + }; +}); + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: {}, + }, + }, +})); + +vi.mock("expo-linking", () => ({ + createURL: vi.fn(() => "cap://auth"), +})); + +vi.mock("expo-secure-store", () => secureStoreMock); + +vi.mock("expo-web-browser", () => ({ + maybeCompleteAuthSession: vi.fn(), + openAuthSessionAsync: vi.fn(), +})); + +vi.mock("@/api/mobile", () => ({ + createMobileApiClient: vi.fn(() => ({ + bootstrap: apiMock.bootstrap, + getAuthConfig: apiMock.getAuthConfig, + revokeSession: vi.fn(() => Promise.resolve({ success: true })), + setActiveOrganization: vi.fn(), + })), + createSessionRequestUrl: vi.fn( + () => "https://cap.so/api/mobile/session/request", + ), +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const flushMicrotasks = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const states: Array<{ + apiKey: string | null; + status: string; + userId: string | null; +}> = []; + +const Probe = () => { + const auth = useAuth(); + states.push({ + apiKey: auth.apiKey, + status: auth.status, + userId: auth.userId, + }); + return null; +}; + +describe("AuthProvider", () => { + beforeEach(() => { + states.length = 0; + secureStoreMock.deleteItemAsync.mockClear(); + secureStoreMock.getItemAsync.mockReset(); + secureStoreMock.getItemAsync.mockResolvedValue(null); + secureStoreMock.setItemAsync.mockClear(); + apiMock.bootstrap.mockReset(); + apiMock.bootstrap.mockResolvedValue({ + activeOrganizationId: "org_123", + user: { + email: "richie@cap.so", + name: "Richie", + }, + }); + apiMock.getAuthConfig.mockReset(); + apiMock.getAuthConfig.mockResolvedValue({ + googleAuthAvailable: true, + workosAuthAvailable: true, + }); + }); + + it("clears an orphaned stored user id when no API key is stored", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => + Promise.resolve(key === "cap.mobile.userId" ? "user_123" : null), + ); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + + expect(states.at(-1)).toMatchObject({ + apiKey: null, + status: "signedOut", + userId: null, + }); + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + "cap.mobile.userId", + ); + }); + + it("clears the stored user id when bootstrapping a stored session fails", async () => { + secureStoreMock.getItemAsync.mockImplementation((key: string) => { + if (key === "cap.mobile.apiKey") return Promise.resolve("key_123"); + if (key === "cap.mobile.userId") return Promise.resolve("user_123"); + return Promise.resolve(null); + }); + apiMock.bootstrap.mockRejectedValueOnce(new Error("Session expired")); + + await act(async () => { + TestRenderer.create( + React.createElement(AuthProvider, null, React.createElement(Probe)), + ); + await flushMicrotasks(); + }); + + expect(states.at(-1)).toMatchObject({ + apiKey: null, + status: "signedOut", + userId: null, + }); + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + "cap.mobile.apiKey", + ); + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + "cap.mobile.userId", + ); + }); +}); diff --git a/apps/mobile/src/auth/SignInPanel.test.tsx b/apps/mobile/src/auth/SignInPanel.test.tsx new file mode 100644 index 0000000000..9970dfb9cb --- /dev/null +++ b/apps/mobile/src/auth/SignInPanel.test.tsx @@ -0,0 +1,1234 @@ +import React, { type ReactElement, type ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SignInPanel } from "./SignInPanel"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +const authFns = vi.hoisted(() => ({ + authConfig: { + googleAuthAvailable: true, + workosAuthAvailable: true, + }, + requestEmailCode: vi.fn(() => Promise.resolve()), + signInWithGoogle: vi.fn(), + signInWithSso: vi.fn(), + verifyEmailCode: vi.fn(), +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderTree = async (node: ReactElement): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return (renderer as ReactTestRenderer | null)?.toJSON() ?? null; +}; + +const renderPanel = async (): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(React.createElement(SignInPanel)); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const hasProp = (node: JsonNode, prop: string, value: unknown): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) + return node.some((item) => hasProp(item, prop, value)); + if (node.props[prop] === value) return true; + return node.children?.some((child) => hasProp(child, prop, value)) ?? false; +}; + +const hasStyle = ( + node: JsonNode, + expected: Record, +): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) return node.some((item) => hasStyle(item, expected)); + const styles = Array.isArray(node.props.style) + ? node.props.style + : [node.props.style]; + const resolved = Object.assign({}, ...styles.filter(Boolean)); + if ( + Object.entries(expected).every(([key, value]) => resolved[key] === value) + ) { + return true; + } + return node.children?.some((child) => hasStyle(child, expected)) ?? false; +}; + +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = typeof style === "function" ? style({ pressed }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + const TextInput = React.forwardRef( + ({ children, ...props }, ref) => + React.createElement( + "TextInput", + { ...props, ref }, + children as ReactNode, + ), + ); + + return { + ActivityIndicator: createHost("ActivityIndicator"), + Pressable: createHost("Pressable"), + StyleSheet: { + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + TextInput, + View: createHost("View"), + }; +}); + +vi.mock("expo-symbols", () => ({ + SymbolView: () => null, +})); + +vi.mock("expo-web-browser", () => ({ + openBrowserAsync: vi.fn(), +})); + +vi.mock("@/components/GlassSurface", async () => { + const React = await import("react"); + return { + GlassSurface: ({ children }: { children?: ReactNode }) => + React.createElement("GlassSurface", null, children), + }; +}); + +vi.mock("react-native-svg", async () => { + const React = await import("react"); + const Svg = ({ children, ...props }: HostProps) => + React.createElement("Svg", props, children); + + return { + default: Svg, + Path: (props: HostProps) => React.createElement("Path", props), + Rect: (props: HostProps) => React.createElement("Rect", props), + }; +}); + +vi.mock("@/auth/AuthContext", () => ({ + apiBaseUrl: "https://cap.so", + useAuth: () => ({ + authConfig: authFns.authConfig, + requestEmailCode: authFns.requestEmailCode, + signInWithGoogle: authFns.signInWithGoogle, + signInWithSso: authFns.signInWithSso, + verifyEmailCode: authFns.verifyEmailCode, + }), +})); + +vi.mock("@/api/mobile", () => ({ + MobileApiError: class MobileApiError extends Error { + status: number; + payload: unknown; + + constructor(message: string, status: number, payload: unknown) { + super(message); + this.status = status; + this.payload = payload; + } + }, +})); + +describe("SignInPanel", () => { + beforeEach(() => { + authFns.authConfig.googleAuthAvailable = true; + authFns.authConfig.workosAuthAvailable = true; + authFns.requestEmailCode.mockReset(); + authFns.requestEmailCode.mockResolvedValue(undefined); + authFns.verifyEmailCode.mockReset(); + authFns.verifyEmailCode.mockResolvedValue(undefined); + authFns.signInWithGoogle.mockReset(); + authFns.signInWithGoogle.mockResolvedValue(undefined); + authFns.signInWithSso.mockReset(); + authFns.signInWithSso.mockResolvedValue(undefined); + }); + + it("renders the Cap web login surface", async () => { + const tree = await renderTree(React.createElement(SignInPanel)); + const text = getTextNodes(tree); + + expect(text).toContain("Sign in to Cap"); + expect(text).toContain("Your videos, organized and ready to share."); + expect(hasProp(tree, "viewBox", "0 0 40 40")).toBe(true); + expect(hasProp(tree, "rx", 8)).toBe(true); + expect(hasProp(tree, "placeholder", "tim@apple.com")).toBe(true); + expect(hasProp(tree, "accessibilityLabel", "Email address")).toBe(true); + expect( + hasProp( + tree, + "accessibilityHint", + "Enter your email to request a verification code", + ), + ).toBe(true); + expect(hasProp(tree, "clearButtonMode", "while-editing")).toBe(true); + expect(hasProp(tree, "enablesReturnKeyAutomatically", true)).toBe(true); + expect(hasProp(tree, "selectionColor", "#0090ff")).toBe(true); + expect( + hasProp( + tree, + "accessibilityHint", + "Enter a valid email address to continue", + ), + ).toBe(true); + expect(text).toContain("Login with email"); + expect(text).toContain("Sign up here"); + expect( + hasProp(tree, "accessibilityHint", "Opens sign up in a browser sheet"), + ).toBe(true); + expect(hasProp(tree, "accessibilityRole", "link")).toBe(true); + expect(text).toContain("OR"); + expect(text).toContain("Login with Google"); + expect(text).toContain("Login with SAML SSO"); + expect(text).toContain("Terms of Service"); + expect(text).toContain("Privacy Policy"); + expect( + hasProp( + tree, + "accessibilityHint", + "Opens Terms of Service in a browser sheet", + ), + ).toBe(true); + expect( + hasProp( + tree, + "accessibilityHint", + "Opens Privacy Policy in a browser sheet", + ), + ).toBe(true); + }); + + it("hides unavailable provider options", async () => { + authFns.authConfig.googleAuthAvailable = false; + authFns.authConfig.workosAuthAvailable = false; + + const tree = await renderTree(React.createElement(SignInPanel)); + const text = getTextNodes(tree); + + expect(text).toContain("Login with email"); + expect(text).not.toContain("OR"); + expect(text).not.toContain("Login with Google"); + expect(text).not.toContain("Login with SAML SSO"); + }); + + it("shows the native SSO organization step", async () => { + const renderer = await renderPanel(); + const [ssoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with SAML SSO", + }); + if (!ssoButton) throw new Error("SSO button was not rendered"); + + await act(async () => { + ssoButton.props.onPress(); + }); + + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(hasProp(tree, "placeholder", "Enter your Organization ID...")).toBe( + true, + ); + expect(hasProp(tree, "accessibilityLabel", "Organization ID")).toBe(true); + expect( + hasProp( + tree, + "accessibilityHint", + "Enter your organization ID to continue with SSO", + ), + ).toBe(true); + expect(hasProp(tree, "clearButtonMode", "while-editing")).toBe(true); + expect(hasProp(tree, "selectionColor", "#0090ff")).toBe(true); + expect( + hasProp( + tree, + "accessibilityHint", + "Enter your organization ID to continue", + ), + ).toBe(true); + const [continueButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Continue with SSO", + }); + expect(continueButton?.props.accessibilityValue).toEqual({ + text: "Organization ID required", + }); + expect(text).toContain("Continue with SSO"); + expect(text).toContain("Back"); + }); + + it("locks the SSO back button while starting sign in", async () => { + let resolveSso: (() => void) | null = null; + authFns.signInWithSso.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSso = resolve; + }), + ); + const renderer = await renderPanel(); + const [ssoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with SAML SSO", + }); + if (!ssoButton) throw new Error("SSO button was not rendered"); + + await act(async () => { + ssoButton.props.onPress(); + }); + + const [organizationInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization ID", + }); + if (!organizationInput) + throw new Error("Organization ID input was not rendered"); + await act(async () => { + organizationInput.props.onChangeText("acme"); + }); + + const [continueButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Continue with SSO", + }); + if (!continueButton) + throw new Error("SSO continue button was not rendered"); + expect(continueButton.props.accessibilityHint).toBe( + "Starts SAML SSO for this organization", + ); + await act(async () => { + void continueButton.props.onPress(); + await Promise.resolve(); + }); + + const [loadingBackButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Back", + }); + const [loadingOrganizationInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization ID", + }); + const [loadingContinueButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Continue with SSO", + }); + expect(loadingBackButton?.props.disabled).toBe(true); + expect(loadingBackButton?.props.accessibilityState).toEqual({ + disabled: true, + }); + expect(loadingBackButton?.props.accessibilityHint).toBe( + "Sign in is in progress", + ); + expect(loadingBackButton?.props.accessibilityValue).toEqual({ + text: "Starting SAML SSO sign in", + }); + expect(loadingOrganizationInput?.props.editable).toBe(false); + expect(loadingOrganizationInput?.props.accessibilityState).toEqual({ + disabled: true, + }); + expect(loadingOrganizationInput?.props.accessibilityValue).toEqual({ + text: "Starting SAML SSO sign in", + }); + expect(loadingContinueButton?.props.accessibilityHint).toBe( + "SAML SSO sign in is starting", + ); + expect(loadingContinueButton?.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loadingContinueButton?.props.accessibilityValue).toEqual({ + text: "Starting SAML SSO sign in", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Continue with SSO"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Starting SSO..."); + + await act(async () => { + organizationInput.props.onChangeText("changed"); + }); + + const [unchangedOrganizationInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization ID", + }); + expect(unchangedOrganizationInput?.props.value).toBe("acme"); + + await act(async () => { + resolveSso?.(); + await Promise.resolve(); + }); + }); + + it("does not request an email code for an invalid email address", async () => { + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie"); + }); + expect(emailButton.props.accessibilityHint).toBe( + "Enter a valid email address to continue", + ); + const [invalidEmailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + expect(invalidEmailButton?.props.accessibilityValue).toEqual({ + text: "Email address is not valid", + }); + expect(emailButton.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + expect(authFns.requestEmailCode).not.toHaveBeenCalled(); + }); + + it("locks the email field while requesting a verification code", async () => { + let resolveRequest: (() => void) | null = null; + authFns.requestEmailCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + void emailButton.props.onPress(); + await Promise.resolve(); + }); + + const [loadingEmailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [loadingEmailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + expect(loadingEmailInput?.props.editable).toBe(false); + expect(loadingEmailInput?.props.accessibilityState).toEqual({ + disabled: true, + }); + expect(loadingEmailInput?.props.accessibilityValue).toEqual({ + text: "Sending verification code", + }); + expect(loadingEmailButton?.props.accessibilityHint).toBe( + "Sending verification code", + ); + expect(loadingEmailButton?.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loadingEmailButton?.props.accessibilityValue).toEqual({ + text: "Sending verification code", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Login with email"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Sending..."); + + await act(async () => { + emailInput.props.onChangeText("changed@cap.so"); + }); + + const [unchangedEmailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + expect(unchangedEmailInput?.props.value).toBe("richie@cap.so"); + + await act(async () => { + resolveRequest?.(); + await Promise.resolve(); + }); + }); + + it("locks browser links while requesting a verification code", async () => { + let resolveRequest: (() => void) | null = null; + authFns.requestEmailCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + void emailButton.props.onPress(); + await Promise.resolve(); + }); + + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + const [signupLink, termsLink, privacyLink] = renderer.root.findAllByProps({ + accessibilityRole: "link", + }); + if (!signupLink || !termsLink || !privacyLink) { + throw new Error("Browser links were not rendered"); + } + + expect(signupLink.props.accessibilityState).toEqual({ disabled: true }); + expect(signupLink.props.accessibilityHint).toBe("Sign in is in progress"); + expect(signupLink.props.accessibilityValue).toEqual({ + text: "Sending verification code", + }); + expect(termsLink.props.accessibilityState).toEqual({ disabled: true }); + expect(termsLink.props.accessibilityHint).toBe("Sign in is in progress"); + expect(termsLink.props.accessibilityValue).toEqual({ + text: "Sending verification code", + }); + expect(privacyLink.props.accessibilityState).toEqual({ disabled: true }); + expect(privacyLink.props.accessibilityHint).toBe("Sign in is in progress"); + expect(privacyLink.props.accessibilityValue).toEqual({ + text: "Sending verification code", + }); + + await act(async () => { + signupLink.props.onPress(); + termsLink.props.onPress(); + privacyLink.props.onPress(); + }); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + + await act(async () => { + resolveRequest?.(); + await Promise.resolve(); + }); + }); + + it("deduplicates sign-in actions while a provider request is pending", async () => { + let resolveGoogle: (() => void) | null = null; + authFns.signInWithGoogle.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveGoogle = resolve; + }), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + if (!emailInput) throw new Error("Email input was not rendered"); + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + const [googleButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with Google", + }); + const [ssoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with SAML SSO", + }); + const [signupLink] = renderer.root.findAllByProps({ + accessibilityHint: "Opens sign up in a browser sheet", + }); + const [termsLink] = renderer.root.findAllByProps({ + accessibilityHint: "Opens Terms of Service in a browser sheet", + }); + const [privacyLink] = renderer.root.findAllByProps({ + accessibilityHint: "Opens Privacy Policy in a browser sheet", + }); + if ( + !emailButton || + !googleButton || + !ssoButton || + !signupLink || + !termsLink || + !privacyLink + ) { + throw new Error("Sign-in actions were not rendered"); + } + + await act(async () => { + void googleButton.props.onPress(); + await Promise.resolve(); + }); + + const [loadingEmailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + const [loadingGoogleButton] = renderer.root.findAll( + (node) => + node.props.accessibilityLabel === "Login with Google" && + node.props.accessibilityHint === "Google sign in is starting", + ); + const [loadingSsoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with SAML SSO", + }); + expect(loadingEmailButton?.props.disabled).toBe(true); + expect(loadingEmailButton?.props.accessibilityHint).toBe( + "Sign in is in progress", + ); + expect(loadingEmailButton?.props.accessibilityValue).toEqual({ + text: "Starting Google sign in", + }); + expect(loadingGoogleButton?.props.accessibilityHint).toBe( + "Google sign in is starting", + ); + expect(loadingGoogleButton?.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loadingGoogleButton?.props.accessibilityValue).toEqual({ + text: "Starting Google sign in", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Login with Google"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Starting Google..."); + expect(loadingSsoButton?.props.disabled).toBe(true); + expect(loadingSsoButton?.props.accessibilityHint).toBe( + "Sign in is in progress", + ); + expect(loadingSsoButton?.props.accessibilityValue).toEqual({ + text: "Starting Google sign in", + }); + + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + + await act(async () => { + googleButton.props.onPress(); + emailButton.props.onPress(); + ssoButton.props.onPress(); + signupLink.props.onPress(); + termsLink.props.onPress(); + privacyLink.props.onPress(); + await Promise.resolve(); + }); + + expect(authFns.signInWithGoogle).toHaveBeenCalledTimes(1); + expect(authFns.requestEmailCode).not.toHaveBeenCalled(); + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(getTextNodes(renderer.toJSON())).not.toContain("Continue with SSO"); + + await act(async () => { + resolveGoogle?.(); + await Promise.resolve(); + }); + }); + + it("marks a failed Google sign-in as retryable", async () => { + authFns.signInWithGoogle.mockRejectedValueOnce( + new Error("Google unavailable"), + ); + const renderer = await renderPanel(); + const [googleButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with Google", + }); + if (!googleButton) throw new Error("Google button was not rendered"); + + await act(async () => { + await googleButton.props.onPress(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain("Google unavailable"); + const [retryGoogleButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Retry Google sign in", + }); + const [errorAlert] = renderer.root.findAllByProps({ + accessibilityRole: "alert", + }); + expect(retryGoogleButton?.props.accessibilityHint).toBe( + "Google unavailable", + ); + expect(retryGoogleButton?.props.accessibilityValue).toEqual({ + text: "Google unavailable", + }); + expect(errorAlert?.props.accessibilityLabel).toBe( + "Sign-in error: Google unavailable", + ); + + await act(async () => { + await retryGoogleButton?.props.onPress(); + }); + + expect(authFns.signInWithGoogle).toHaveBeenCalledTimes(2); + expect(getTextNodes(renderer.toJSON())).not.toContain("Google unavailable"); + expect( + renderer.root.findAllByProps({ + accessibilityLabel: "Retry Google sign in", + }), + ).toHaveLength(0); + }); + + it("marks a failed SSO sign-in on the organization step", async () => { + authFns.signInWithSso.mockRejectedValueOnce(new Error("SSO unavailable")); + const renderer = await renderPanel(); + const [ssoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with SAML SSO", + }); + if (!ssoButton) throw new Error("SSO button was not rendered"); + + await act(async () => { + ssoButton.props.onPress(); + }); + + const [organizationInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization ID", + }); + if (!organizationInput) + throw new Error("Organization ID input was not rendered"); + await act(async () => { + organizationInput.props.onChangeText("acme"); + }); + + const [continueButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Continue with SSO", + }); + if (!continueButton) + throw new Error("SSO continue button was not rendered"); + await act(async () => { + await continueButton.props.onPress(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain("SSO unavailable"); + const [organizationInputAfterError] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization ID", + }); + const [retrySsoButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Retry SAML SSO sign in", + }); + const [errorAlert] = renderer.root.findAllByProps({ + accessibilityRole: "alert", + }); + expect(organizationInputAfterError?.props.accessibilityHint).toBe( + "SSO unavailable", + ); + expect(retrySsoButton?.props.accessibilityHint).toBe("SSO unavailable"); + expect(retrySsoButton?.props.accessibilityValue).toEqual({ + text: "SSO unavailable", + }); + expect(errorAlert?.props.accessibilityLabel).toBe( + "Sign-in error: SSO unavailable", + ); + expect( + hasStyle(renderer.toJSON(), { + borderColor: "#f4a9aa", + }), + ).toBe(true); + + await act(async () => { + await retrySsoButton?.props.onPress(); + }); + + expect(authFns.signInWithSso).toHaveBeenCalledTimes(2); + expect(getTextNodes(renderer.toJSON())).not.toContain("SSO unavailable"); + expect( + renderer.root.findAllByProps({ + accessibilityLabel: "Retry SAML SSO sign in", + }), + ).toHaveLength(0); + }); + + it("shows the right error when an email is not allowed", async () => { + const { MobileApiError } = await import("@/api/mobile"); + authFns.requestEmailCode.mockRejectedValueOnce( + new MobileApiError("Forbidden", 403, null), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("blocked@example.com"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain( + "This email cannot be used to sign in to Cap.", + ); + const [emailInputAfterError] = renderer.root.findAllByProps({ + accessibilityLabel: "Email address", + }); + const [errorAlert] = renderer.root.findAllByProps({ + accessibilityRole: "alert", + }); + expect(emailInputAfterError?.props.accessibilityHint).toBe( + "This email cannot be used to sign in to Cap.", + ); + const [emailButtonAfterError] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + expect(emailButtonAfterError?.props.accessibilityValue).toEqual({ + text: "This email cannot be used to sign in to Cap.", + }); + expect(errorAlert?.props.accessibilityLabel).toBe( + "Sign-in error: This email cannot be used to sign in to Cap.", + ); + expect(errorAlert?.props.accessibilityLiveRegion).toBe("polite"); + expect( + hasStyle(renderer.toJSON(), { + borderColor: "#f4a9aa", + }), + ).toBe(true); + expect( + hasStyle(renderer.toJSON(), { + backgroundColor: "#fffcfc", + borderColor: "#fdbdbe", + }), + ).toBe(true); + }); + + it("switches to a web-like verification code step after requesting email", async () => { + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(authFns.requestEmailCode).toHaveBeenCalledWith("richie@cap.so"); + expect(text).toContain("Back"); + expect(text).toContain("Enter verification code"); + expect(text).toContain("We sent a 6-digit code to richie@cap.so"); + expect(text).toContain("Verify Code"); + expect(text).toContain("Resend in 30s"); + expect(text).toContain("Terms of Service"); + expect(hasProp(tree, "accessibilityLabel", "Verification code")).toBe(true); + const [codeTarget] = renderer.root.findAllByProps({ + accessibilityLabel: "Verification code", + }); + const [verifyButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Verify Code", + }); + const [resendButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Resend in 30s", + }); + const [codeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + if (!codeInput) throw new Error("One-time code input was not rendered"); + expect(codeTarget?.props.accessibilityValue).toEqual({ + text: "0 of 6 digits entered", + }); + expect(verifyButton?.props.accessibilityValue).toEqual({ + text: "0 of 6 digits entered", + }); + expect(resolveStyle(verifyButton?.props.style)).toMatchObject({ + backgroundColor: "#d9d9d9", + borderColor: "#d9d9d9", + }); + expect(resendButton?.props.accessibilityValue).toEqual({ + text: "Wait 30 seconds", + }); + expect(hasStyle(tree, { gap: 20, paddingTop: 2 })).toBe(true); + expect( + hasProp(tree, "accessibilityHint", "Tap to enter the 6-digit code"), + ).toBe(true); + expect( + hasProp(tree, "accessibilityHint", "Enter the 6-digit code to continue"), + ).toBe(true); + expect(hasProp(tree, "accessibilityHint", "Returns to email sign in")).toBe( + true, + ); + expect(hasProp(tree, "accessibilityElementsHidden", true)).toBe(true); + expect(hasProp(tree, "accessible", false)).toBe(true); + expect( + hasProp(tree, "importantForAccessibility", "no-hide-descendants"), + ).toBe(true); + expect(hasProp(tree, "selectionColor", "#0090ff")).toBe(true); + await act(async () => { + codeInput.props.onFocus(); + }); + expect( + hasStyle(renderer.toJSON(), { + backgroundColor: "#f9f9f9", + borderColor: "#0090ff", + shadowOpacity: 0.12, + }), + ).toBe(true); + await act(async () => { + codeInput.props.onBlur(); + }); + expect( + hasStyle(renderer.toJSON(), { + backgroundColor: "#f9f9f9", + borderColor: "#0090ff", + shadowOpacity: 0.12, + }), + ).toBe(false); + expect(text).not.toContain("Login with Google"); + }); + + it("verifies an autofilled one-time code when all six digits are entered", async () => { + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [codeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + if (!codeInput) throw new Error("One-time code input was not rendered"); + + await act(async () => { + codeInput.props.onChangeText("123-456"); + await Promise.resolve(); + }); + + expect(authFns.verifyEmailCode).toHaveBeenCalledWith( + "richie@cap.so", + "123456", + ); + }); + + it("marks invalid verification codes on the visible code target", async () => { + const { MobileApiError } = await import("@/api/mobile"); + authFns.verifyEmailCode.mockRejectedValueOnce( + new MobileApiError("Forbidden", 403, null), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [codeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + if (!codeInput) throw new Error("One-time code input was not rendered"); + + await act(async () => { + codeInput.props.onChangeText("123456"); + await Promise.resolve(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain( + "That code is invalid or expired.", + ); + const [codeTarget] = renderer.root.findAllByProps({ + accessibilityLabel: "Verification code", + }); + const [errorAlert] = renderer.root.findAllByProps({ + accessibilityRole: "alert", + }); + expect(codeTarget?.props.accessibilityHint).toBe( + "That code is invalid or expired.", + ); + expect(codeTarget?.props.accessibilityValue).toEqual({ + text: "0 of 6 digits entered", + }); + expect(errorAlert?.props.accessibilityLabel).toBe( + "Sign-in error: That code is invalid or expired.", + ); + expect( + hasStyle(renderer.toJSON(), { + backgroundColor: "#fffcfc", + borderColor: "#f4a9aa", + }), + ).toBe(true); + }); + + it("locks the visible code entry target while verifying", async () => { + let resolveVerify: (() => void) | null = null; + authFns.verifyEmailCode.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveVerify = resolve; + }), + ); + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [codeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + if (!codeInput) throw new Error("One-time code input was not rendered"); + + await act(async () => { + codeInput.props.onChangeText("123456"); + await Promise.resolve(); + }); + + const [codeTarget] = renderer.root.findAllByProps({ + accessibilityLabel: "Verification code", + }); + const [loadingBackButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Back", + }); + const [loadingCodeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + const [loadingVerifyButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Verify Code", + }); + expect(codeTarget?.props.disabled).toBe(true); + expect(codeTarget?.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(codeTarget?.props.accessibilityHint).toBe("Verifying code"); + expect(codeTarget?.props.accessibilityValue).toEqual({ + text: "Verifying code", + }); + expect(loadingBackButton?.props.accessibilityHint).toBe( + "Sign in is in progress", + ); + expect(loadingBackButton?.props.accessibilityValue).toEqual({ + text: "Verifying code", + }); + expect(loadingCodeInput?.props.editable).toBe(false); + expect(loadingVerifyButton?.props.accessibilityHint).toBe("Verifying code"); + expect(loadingVerifyButton?.props.accessibilityValue).toEqual({ + text: "Verifying code", + }); + expect(loadingVerifyButton?.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(getTextNodes(renderer.toJSON())).not.toContain("Verifying..."); + + await act(async () => { + codeInput.props.onChangeText("654321"); + await Promise.resolve(); + }); + + const [unchangedCodeInput] = renderer.root.findAllByProps({ + textContentType: "oneTimeCode", + }); + expect(unchangedCodeInput?.props.value).toBe("123456"); + expect(authFns.verifyEmailCode).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveVerify?.(); + await Promise.resolve(); + }); + }); + + it("prevents repeated email code requests during the resend cooldown", async () => { + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("richie@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [resendButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Resend in 30s", + }); + if (!resendButton) throw new Error("Resend control was not rendered"); + + expect(resendButton.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + expect(resendButton.props.accessibilityHint).toBe( + "Wait 30 seconds before requesting a new code", + ); + expect(resendButton.props.accessibilityValue).toEqual({ + text: "Wait 30 seconds", + }); + expect(resendButton.props.hitSlop).toBe(6); + expect( + hasStyle(renderer.toJSON(), { + color: "#8d8d8d", + textDecorationLine: "none", + }), + ).toBe(true); + + await act(async () => { + await resendButton.props.onPress(); + }); + + expect(authFns.requestEmailCode).toHaveBeenCalledTimes(1); + expect(getTextNodes(renderer.toJSON())).toContain( + "Please wait 30 seconds before requesting a new code.", + ); + }); + + it("allows a corrected email to request a code without waiting for the previous cooldown", async () => { + const renderer = await renderPanel(); + const [emailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [emailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!emailInput || !emailButton) { + throw new Error("Email sign in controls were not rendered"); + } + + await act(async () => { + emailInput.props.onChangeText("wrong@cap.so"); + }); + await act(async () => { + await emailButton.props.onPress(); + }); + + const [backButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Back", + }); + if (!backButton) throw new Error("Back button was not rendered"); + + await act(async () => { + backButton.props.onPress(); + }); + + const [correctedEmailInput] = renderer.root.findAllByProps({ + placeholder: "tim@apple.com", + }); + const [correctedEmailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + if (!correctedEmailInput || !correctedEmailButton) { + throw new Error("Corrected email sign in controls were not rendered"); + } + + await act(async () => { + correctedEmailInput.props.onChangeText("right@cap.so"); + }); + + const [readyEmailButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Login with email", + }); + expect(readyEmailButton?.props.accessibilityState).toEqual({ + busy: false, + disabled: false, + }); + + await act(async () => { + await readyEmailButton?.props.onPress(); + }); + + expect(authFns.requestEmailCode).toHaveBeenNthCalledWith(1, "wrong@cap.so"); + expect(authFns.requestEmailCode).toHaveBeenNthCalledWith(2, "right@cap.so"); + }); +}); diff --git a/apps/mobile/src/auth/SignInPanel.tsx b/apps/mobile/src/auth/SignInPanel.tsx new file mode 100644 index 0000000000..27f6f3eae0 --- /dev/null +++ b/apps/mobile/src/auth/SignInPanel.tsx @@ -0,0 +1,1053 @@ +import { SymbolView } from "expo-symbols"; +import * as WebBrowser from "expo-web-browser"; +import { useEffect, useRef, useState } from "react"; +import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import { MobileApiError } from "@/api/mobile"; +import { ActionButton } from "@/components/ActionButton"; +import { CapLogoBadge } from "@/components/CapLogoBadge"; +import { GlassSurface } from "@/components/GlassSurface"; +import { colors, fonts, radius, squircle } from "@/theme"; +import { apiBaseUrl, useAuth } from "./AuthContext"; + +type SignInPanelProps = { + title?: string; + subtitle?: string; +}; + +const codePattern = /^\d{6}$/; +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const emailCodeCooldownMs = 30_000; +const codeSlots = ["code-0", "code-1", "code-2", "code-3", "code-4", "code-5"]; +type FocusedInput = "code" | "email" | "sso" | null; +type LoadingKind = "email" | "code" | "google" | "sso"; +type SignInError = { + message: string; + source: LoadingKind | "resend"; +}; + +const getEmailRequestErrorMessage = (error: unknown) => { + if (error instanceof MobileApiError) { + if (error.status === 400) return "Enter a valid email address."; + if (error.status === 403) { + return "This email cannot be used to sign in to Cap."; + } + } + return error instanceof Error + ? error.message + : "Unable to send a code. Try again."; +}; + +const getCodeVerificationErrorMessage = (error: unknown) => { + if (error instanceof MobileApiError) { + if (error.status === 400) return "Enter a valid email and 6-digit code."; + if (error.status === 403) return "That code is invalid or expired."; + } + return error instanceof Error ? error.message : "Unable to verify that code."; +}; + +const getProviderErrorMessage = (error: unknown, fallback: string) => { + return error instanceof Error ? error.message : fallback; +}; + +const openWebPath = (path: string) => { + void WebBrowser.openBrowserAsync(new URL(path, apiBaseUrl).toString()); +}; + +function GoogleMark() { + return ( + + + + + + + ); +} + +export function SignInPanel({ + title = "Sign in to Cap", + subtitle = "Your videos, organized and ready to share.", +}: SignInPanelProps) { + const auth = useAuth(); + const codeInputRef = useRef(null); + const loadingRef = useRef(false); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [organizationId, setOrganizationId] = useState(""); + const [codeSent, setCodeSent] = useState(false); + const [lastCodeRequestedAt, setLastCodeRequestedAt] = useState( + null, + ); + const [lastCodeRequestedEmail, setLastCodeRequestedEmail] = useState< + string | null + >(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [showSso, setShowSso] = useState(false); + const [focusedInput, setFocusedInput] = useState(null); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(null); + + const normalizedEmail = email.trim().toLowerCase(); + const normalizedOrganizationId = organizationId.trim(); + const cooldownEndsAt = + lastCodeRequestedAt !== null && lastCodeRequestedEmail === normalizedEmail + ? lastCodeRequestedAt + emailCodeCooldownMs + : null; + const cooldownRemainingMs = + cooldownEndsAt !== null ? Math.max(0, cooldownEndsAt - nowMs) : 0; + const cooldownRemainingSeconds = Math.ceil(cooldownRemainingMs / 1000); + const isCodeRequestCoolingDown = cooldownRemainingSeconds > 0; + const isEmailReady = emailPattern.test(normalizedEmail); + const isCodeReady = codePattern.test(code); + const isSsoReady = normalizedOrganizationId.length > 0; + const canRequestCode = + isEmailReady && loading === null && !isCodeRequestCoolingDown; + const canVerifyCode = isCodeReady && loading === null; + const canStartSso = isSsoReady && loading === null; + const isCodeStep = codeSent && !showSso; + const showBackButton = showSso || isCodeStep; + const showGoogle = auth.authConfig.googleAuthAvailable; + const showSaml = auth.authConfig.workosAuthAvailable; + const showProviderOptions = showGoogle || showSaml; + const errorMessage = error?.message ?? null; + const emailInputHasError = + error?.source === "email" && !showSso && !isCodeStep; + const ssoInputHasError = error?.source === "sso" && showSso; + const codeEntryHasError = error?.source === "code" && isCodeStep; + const googleActionHasError = + error?.source === "google" && !showSso && !isCodeStep; + const ssoActionHasError = error?.source === "sso" && showSso; + const backDisabled = loading !== null; + const codeEntryDisabled = loading !== null; + const activeCodeSlotIndex = Math.min(code.length, codeSlots.length - 1); + const linkDisabled = loading !== null; + const resendDisabled = loading !== null || isCodeRequestCoolingDown; + const headerTitle = isCodeStep ? "Enter verification code" : title; + const headerSubtitle = isCodeStep + ? `We sent a 6-digit code to ${normalizedEmail}` + : subtitle; + const resendLabel = isCodeRequestCoolingDown + ? `Resend in ${cooldownRemainingSeconds}s` + : "Didn't receive the code? Resend"; + const emailButtonLabel = "Login with email"; + const verifyButtonLabel = "Verify Code"; + const googleButtonLabel = googleActionHasError + ? "Retry Google" + : "Login with Google"; + const ssoContinueButtonLabel = ssoActionHasError + ? "Retry SSO" + : "Continue with SSO"; + const googleButtonAccessibilityLabel = googleActionHasError + ? "Retry Google sign in" + : undefined; + const ssoContinueButtonAccessibilityLabel = ssoActionHasError + ? "Retry SAML SSO sign in" + : undefined; + const activeSignInAccessibilityText = + loading === "email" + ? "Sending verification code" + : loading === "code" + ? "Verifying code" + : loading === "google" + ? "Starting Google sign in" + : loading === "sso" + ? "Starting SAML SSO sign in" + : null; + const activeSignInAccessibilityValue = activeSignInAccessibilityText + ? { text: activeSignInAccessibilityText } + : undefined; + const emailButtonAccessibilityValue = + loading === "email" + ? activeSignInAccessibilityValue + : emailInputHasError && errorMessage + ? { text: errorMessage } + : normalizedEmail.length > 0 && !isEmailReady + ? { text: "Email address is not valid" } + : loading !== null + ? activeSignInAccessibilityValue + : undefined; + const verifyButtonAccessibilityValue = + loading === "code" + ? activeSignInAccessibilityValue + : isCodeStep + ? { text: `${code.length} of 6 digits entered` } + : undefined; + const googleButtonAccessibilityValue = + loading === "google" + ? activeSignInAccessibilityValue + : googleActionHasError && errorMessage + ? { text: errorMessage } + : loading !== null + ? activeSignInAccessibilityValue + : undefined; + const samlButtonAccessibilityValue = + loading !== null ? activeSignInAccessibilityValue : undefined; + const ssoContinueButtonAccessibilityValue = + loading === "sso" + ? activeSignInAccessibilityValue + : ssoActionHasError && errorMessage + ? { text: errorMessage } + : normalizedOrganizationId.length > 0 + ? undefined + : { text: "Organization ID required" }; + const resendAccessibilityLabel = + loading === "email" ? "Didn't receive the code? Resend" : resendLabel; + const resendAccessibilityValue = + loading === "email" + ? activeSignInAccessibilityValue + : loading !== null + ? activeSignInAccessibilityValue + : isCodeRequestCoolingDown + ? { text: `Wait ${cooldownRemainingSeconds} seconds` } + : undefined; + const codeEntryAccessibilityValue = + loading === "code" + ? activeSignInAccessibilityValue + : { text: `${code.length} of 6 digits entered` }; + const linkAccessibilityValue = + loading !== null ? activeSignInAccessibilityValue : undefined; + const emailInputAccessibilityValue = + loading !== null ? activeSignInAccessibilityValue : undefined; + const ssoInputAccessibilityValue = + loading !== null + ? activeSignInAccessibilityValue + : ssoInputHasError && errorMessage + ? { text: errorMessage } + : undefined; + const backButtonAccessibilityValue = + loading !== null ? activeSignInAccessibilityValue : undefined; + const resendHint = + loading !== null + ? "Sign in is in progress" + : isCodeRequestCoolingDown + ? `Wait ${cooldownRemainingSeconds} seconds before requesting a new code` + : "Requests a new verification code"; + const emailButtonHint = + loading === "email" + ? "Sending verification code" + : loading !== null + ? "Sign in is in progress" + : !isEmailReady + ? "Enter a valid email address to continue" + : "Sends a verification code to this email"; + const verifyButtonHint = + loading === "code" + ? "Verifying code" + : loading !== null + ? "Sign in is in progress" + : !isCodeReady + ? "Enter the 6-digit code to continue" + : "Verifies the 6-digit code"; + const googleButtonHint = + loading === "google" + ? "Google sign in is starting" + : loading !== null + ? "Sign in is in progress" + : googleActionHasError && errorMessage + ? errorMessage + : "Starts Google sign in"; + const samlButtonHint = + loading !== null + ? "Sign in is in progress" + : "Shows the SSO organization step"; + const ssoButtonHint = + loading === "sso" + ? "SAML SSO sign in is starting" + : loading !== null + ? "Sign in is in progress" + : ssoActionHasError && errorMessage + ? errorMessage + : !isSsoReady + ? "Enter your organization ID to continue" + : "Starts SAML SSO for this organization"; + const emailInputHint = + emailInputHasError && errorMessage + ? errorMessage + : "Enter your email to request a verification code"; + const ssoInputHint = + ssoInputHasError && errorMessage + ? errorMessage + : "Enter your organization ID to continue with SSO"; + const codeEntryHint = + codeEntryHasError && errorMessage + ? errorMessage + : loading === "code" + ? "Verifying code" + : loading !== null + ? "Sign in is in progress" + : "Tap to enter the 6-digit code"; + const signupLinkHint = + loading !== null + ? "Sign in is in progress" + : "Opens sign up in a browser sheet"; + const termsLinkHint = + loading !== null + ? "Sign in is in progress" + : "Opens Terms of Service in a browser sheet"; + const privacyLinkHint = + loading !== null + ? "Sign in is in progress" + : "Opens Privacy Policy in a browser sheet"; + const backButtonHint = + loading !== null + ? "Sign in is in progress" + : isCodeStep + ? "Returns to email sign in" + : "Returns to sign in options"; + + const beginLoading = (nextLoading: LoadingKind) => { + if (loadingRef.current || loading !== null) return false; + loadingRef.current = true; + setLoading(nextLoading); + return true; + }; + + const endLoading = () => { + loadingRef.current = false; + setLoading(null); + }; + + const isAuthBusy = () => loadingRef.current || loading !== null; + + useEffect(() => { + if (!isCodeRequestCoolingDown) return; + const interval = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(interval); + }, [isCodeRequestCoolingDown]); + + const goBack = () => { + if (isAuthBusy()) return; + if (showSso) setShowSso(false); + if (isCodeStep) { + setCodeSent(false); + setCode(""); + } + setFocusedInput(null); + setError(null); + }; + + const updateOrganizationId = (value: string) => { + if (isAuthBusy()) return; + setOrganizationId(value.trim()); + setError(null); + }; + + const updateEmail = (value: string) => { + if (isAuthBusy()) return; + setEmail(value.toLowerCase()); + setCodeSent(false); + setCode(""); + setError(null); + }; + + const requestCode = async () => { + if (!emailPattern.test(normalizedEmail)) return; + if (isCodeRequestCoolingDown) { + setError({ + message: `Please wait ${cooldownRemainingSeconds} seconds before requesting a new code.`, + source: "resend", + }); + return; + } + if (!beginLoading("email")) return; + setError(null); + try { + await auth.requestEmailCode(normalizedEmail); + const requestedAt = Date.now(); + setEmail(normalizedEmail); + setCode(""); + setCodeSent(true); + setLastCodeRequestedAt(requestedAt); + setLastCodeRequestedEmail(normalizedEmail); + setNowMs(requestedAt); + } catch (requestError) { + setError({ + message: getEmailRequestErrorMessage(requestError), + source: "email", + }); + } finally { + endLoading(); + } + }; + + const verifyCode = async (codeToVerify = code) => { + if (!codePattern.test(codeToVerify)) return; + if (!beginLoading("code")) return; + setError(null); + try { + await auth.verifyEmailCode(normalizedEmail, codeToVerify); + } catch (verifyError) { + setError({ + message: getCodeVerificationErrorMessage(verifyError), + source: "code", + }); + setCode(""); + } finally { + endLoading(); + } + }; + + const updateCode = (value: string) => { + if (isAuthBusy()) return; + const nextCode = value.replace(/\D/g, "").slice(0, 6); + setCode(nextCode); + setError(null); + if (codePattern.test(nextCode)) void verifyCode(nextCode); + }; + + const submitCode = () => { + void verifyCode(); + }; + + const focusCodeInput = () => { + if (codeEntryDisabled) return; + setFocusedInput("code"); + codeInputRef.current?.focus(); + }; + + const openWebPathIfIdle = (path: string) => { + if (isAuthBusy()) return; + openWebPath(path); + }; + + const signInWithGoogle = async () => { + if (!beginLoading("google")) return; + setError(null); + try { + await auth.signInWithGoogle(); + } catch (googleError) { + setError({ + message: getProviderErrorMessage( + googleError, + "Unable to start Google sign in.", + ), + source: "google", + }); + } finally { + endLoading(); + } + }; + + const signInWithSso = async () => { + if (normalizedOrganizationId.length === 0) return; + if (!beginLoading("sso")) return; + setError(null); + try { + await auth.signInWithSso(normalizedOrganizationId); + } catch (ssoError) { + setError({ + message: getProviderErrorMessage( + ssoError, + "Unable to start SSO sign in.", + ), + source: "sso", + }); + } finally { + endLoading(); + } + }; + + const showSsoStep = () => { + if (isAuthBusy()) return; + setShowSso(true); + setCodeSent(false); + setCode(""); + setError(null); + }; + + return ( + + + {showBackButton ? ( + [ + styles.backPill, + pressed && !backDisabled && styles.backPillPressed, + backDisabled && styles.backPillDisabled, + ]} + > + + + Back + + + ) : null} + + + + + + {headerTitle} + + + {headerSubtitle} + + + + {showSso ? ( + + setFocusedInput(null)} + onFocus={() => setFocusedInput("sso")} + onSubmitEditing={signInWithSso} + style={[ + styles.input, + focusedInput === "sso" && styles.inputFocused, + ssoInputHasError && styles.inputError, + ]} + /> + + + ) : isCodeStep ? ( + + + {codeSlots.map((slot, index) => ( + + {code[index] ?? ""} + + ))} + setFocusedInput(null)} + onFocus={() => setFocusedInput("code")} + onSubmitEditing={submitCode} + maxLength={6} + importantForAccessibility="no-hide-descendants" + selectionColor={colors.blue9} + textContentType="oneTimeCode" + style={styles.codeInput} + /> + + + + ) : ( + <> + setFocusedInput(null)} + onFocus={() => setFocusedInput("email")} + onSubmitEditing={requestCode} + style={[ + styles.input, + focusedInput === "email" && styles.inputFocused, + emailInputHasError && styles.inputError, + ]} + /> + + + )} + {errorMessage ? ( + + + {errorMessage} + + ) : null} + {isCodeStep ? ( + + + + {resendLabel} + + + + ) : null} + {showSso || isCodeStep ? null : ( + <> + + Don't have an account?{" "} + openWebPathIfIdle("signup")} + > + Sign up here + + + {showProviderOptions ? ( + <> + + + OR + + + {showGoogle ? ( + } + onPress={signInWithGoogle} + loading={loading === "google"} + disabled={loading !== null} + variant="gray" + size="md" + /> + ) : null} + {showSaml ? ( + + ) : null} + + ) : null} + + )} + + {isCodeStep + ? "By entering your email, you acknowledge that you have both read and agree to Cap's " + : "By typing your email and clicking continue, you acknowledge that you have both read and agree to Cap's "} + openWebPathIfIdle("terms")} + > + Terms of Service + {" "} + and{" "} + openWebPathIfIdle("privacy")} + > + Privacy Policy + + . + + + + + ); +} + +const styles = StyleSheet.create({ + shell: { + flex: 1, + justifyContent: "center", + paddingVertical: 22, + }, + card: { + width: "100%", + maxWidth: 432, + alignSelf: "center", + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray5, + paddingHorizontal: 28, + paddingVertical: 28, + gap: 28, + ...squircle, + }, + cardFallback: { + backgroundColor: colors.gray3, + }, + backPill: { + position: "absolute", + left: 20, + top: 20, + zIndex: 2, + minHeight: 30, + flexDirection: "row", + alignItems: "center", + gap: 8, + borderRadius: radius.full, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray4, + paddingHorizontal: 12, + backgroundColor: "transparent", + ...squircle, + }, + backPillPressed: { + backgroundColor: colors.gray1, + }, + backPillDisabled: { + opacity: 0.55, + }, + backPillText: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 17, + color: colors.gray12, + }, + backPillTextDisabled: { + color: colors.gray9, + }, + brandBlock: { + alignItems: "center", + }, + header: { + alignItems: "center", + gap: 8, + }, + title: { + fontFamily: fonts.medium, + fontSize: 24, + lineHeight: 30, + color: colors.gray12, + textAlign: "center", + }, + codeTitle: { + fontSize: 20, + lineHeight: 26, + }, + subtitle: { + fontFamily: fonts.regular, + fontSize: 16, + lineHeight: 22, + color: colors.gray10, + textAlign: "center", + }, + codeSubtitle: { + fontSize: 14, + lineHeight: 20, + }, + formStack: { + gap: 12, + }, + input: { + minHeight: 44, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray4, + backgroundColor: colors.gray1, + paddingHorizontal: 14, + fontFamily: fonts.regular, + fontSize: 16, + color: colors.gray12, + ...squircle, + }, + inputFocused: { + backgroundColor: colors.gray2, + borderWidth: 1, + borderColor: colors.gray5, + shadowColor: colors.gray12, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.12, + shadowRadius: 1, + }, + inputError: { + backgroundColor: colors.red1, + borderWidth: 1, + borderColor: colors.red7, + }, + codeSection: { + gap: 20, + paddingTop: 2, + }, + codeBoxes: { + flexDirection: "row", + gap: 8, + justifyContent: "space-between", + position: "relative", + }, + codeBoxesDisabled: { + opacity: 0.68, + }, + codeBox: { + flex: 1, + height: 52, + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray5, + backgroundColor: colors.gray1, + alignItems: "center", + justifyContent: "center", + ...squircle, + }, + codeBoxActive: { + borderColor: colors.blue9, + }, + codeBoxFocused: { + backgroundColor: colors.gray2, + borderWidth: 1, + shadowColor: colors.gray12, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.12, + shadowRadius: 1, + }, + codeBoxError: { + backgroundColor: colors.red1, + borderColor: colors.red7, + }, + codeDigit: { + fontFamily: fonts.medium, + fontSize: 22, + lineHeight: 27, + color: colors.gray12, + textAlign: "center", + }, + ssoStack: { + gap: 10, + }, + codeInput: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + }, + codeLinks: { + alignItems: "center", + marginTop: 2, + }, + resendButton: { + minHeight: 30, + justifyContent: "center", + paddingHorizontal: 4, + }, + resendText: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + textDecorationLine: "underline", + }, + resendTextDisabled: { + color: colors.gray9, + textDecorationLine: "none", + }, + errorBanner: { + minHeight: 42, + flexDirection: "row", + alignItems: "center", + gap: 8, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.red6, + backgroundColor: colors.red1, + paddingHorizontal: 12, + paddingVertical: 10, + ...squircle, + }, + dividerRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 3, + }, + divider: { + flex: 1, + height: StyleSheet.hairlineWidth, + backgroundColor: colors.gray5, + }, + dividerText: { + fontFamily: fonts.medium, + fontSize: 12, + textTransform: "uppercase", + color: colors.gray9, + }, + legalText: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 18, + color: colors.gray9, + textAlign: "center", + }, + legalLink: { + fontFamily: fonts.medium, + color: colors.gray12, + }, + linkDisabled: { + opacity: 0.55, + }, + signupText: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 18, + color: colors.gray9, + textAlign: "center", + }, + signupLink: { + fontFamily: fonts.medium, + color: colors.blue9, + }, + errorText: { + flex: 1, + fontFamily: fonts.medium, + fontSize: 14, + lineHeight: 20, + color: colors.red9, + }, +}); diff --git a/apps/mobile/src/auth/session.ts b/apps/mobile/src/auth/session.ts new file mode 100644 index 0000000000..be8f9b87fe --- /dev/null +++ b/apps/mobile/src/auth/session.ts @@ -0,0 +1,20 @@ +export const parseAuthRedirect = (url: string) => { + const parsed = new URL(url); + const error = parsed.searchParams.get("error_description"); + if (error) throw new Error(error); + + const apiKey = parsed.searchParams.get("api_key"); + const userId = parsed.searchParams.get("user_id"); + + if (!apiKey) return null; + return { + apiKey, + userId, + }; +}; + +export const requireAuthRedirectSession = (url: string) => { + const session = parseAuthRedirect(url); + if (!session) throw new Error("Sign in did not return a mobile session."); + return session; +}; diff --git a/apps/mobile/src/auth/signInDestination.test.ts b/apps/mobile/src/auth/signInDestination.test.ts new file mode 100644 index 0000000000..8ca3f9b70e --- /dev/null +++ b/apps/mobile/src/auth/signInDestination.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { signInTitleForSegments } from "./signInDestination"; + +describe("signInTitleForSegments", () => { + it("uses contextual auth titles for deep-linked mobile surfaces", () => { + expect(signInTitleForSegments(["(tabs)", "upload"])).toBe( + "Sign in to import", + ); + expect(signInTitleForSegments(["caps", "[id]"])).toBe("Sign in to view"); + expect(signInTitleForSegments(["(tabs)"])).toBe("Sign in to Cap"); + }); +}); diff --git a/apps/mobile/src/auth/signInDestination.ts b/apps/mobile/src/auth/signInDestination.ts new file mode 100644 index 0000000000..033439d146 --- /dev/null +++ b/apps/mobile/src/auth/signInDestination.ts @@ -0,0 +1,5 @@ +export const signInTitleForSegments = (segments: readonly string[]) => { + if (segments.includes("upload")) return "Sign in to import"; + if (segments.includes("caps")) return "Sign in to view"; + return "Sign in to Cap"; +}; From 43d09019622139e66c29d9a140324e9843691459 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 10/20] feat(mobile): add mobile API client --- apps/mobile/src/api/mobile.test.ts | 447 +++++++++++++++++++++++++++ apps/mobile/src/api/mobile.ts | 479 +++++++++++++++++++++++++++++ 2 files changed, 926 insertions(+) create mode 100644 apps/mobile/src/api/mobile.test.ts create mode 100644 apps/mobile/src/api/mobile.ts diff --git a/apps/mobile/src/api/mobile.test.ts b/apps/mobile/src/api/mobile.test.ts new file mode 100644 index 0000000000..a4d1407a0f --- /dev/null +++ b/apps/mobile/src/api/mobile.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createMobileApiClient, + createSessionRequestUrl, + uploadToTarget, +} from "./mobile"; + +const fileSystemMock = vi.hoisted(() => ({ + FileSystemUploadType: { + BINARY_CONTENT: 0, + MULTIPART: 1, + }, + createUploadTask: vi.fn(), + getInfoAsync: vi.fn(), +})); + +vi.mock("expo-file-system/legacy", () => fileSystemMock); + +describe("createMobileApiClient", () => { + it("decodes bootstrap responses through shared schemas", async () => { + const calls: RequestInfo[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(input as RequestInfo); + return new Response( + JSON.stringify({ + user: { + id: "user_123", + name: "Richie", + email: "richie@example.com", + imageUrl: null, + activeOrganizationId: "org_123", + }, + organizations: [ + { + id: "org_123", + name: "Cap", + iconUrl: null, + role: "owner", + }, + ], + activeOrganizationId: "org_123", + rootFolders: [], + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => "api-key", + }); + const result = await client.bootstrap(); + expect(result.user.email).toBe("richie@example.com"); + expect(String(calls[0])).toBe("https://cap.so/api/mobile/bootstrap"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("decodes public auth provider config", async () => { + const calls: RequestInfo[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(input as RequestInfo); + return new Response( + JSON.stringify({ + googleAuthAvailable: false, + workosAuthAvailable: true, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => null, + }); + const result = await client.getAuthConfig(); + expect(result.googleAuthAvailable).toBe(false); + expect(result.workosAuthAvailable).toBe(true); + expect(String(calls[0])).toBe("https://cap.so/api/mobile/session/config"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("builds Google session request URLs", () => { + expect( + createSessionRequestUrl("https://cap.so/", "cap://auth", "google"), + ).toBe( + "https://cap.so/api/mobile/session/request?redirectUri=cap%3A%2F%2Fauth&provider=google", + ); + }); + + it("builds WorkOS session request URLs", () => { + expect( + createSessionRequestUrl( + "https://cap.so/", + "cap://auth", + "workos", + "org_123", + ), + ).toBe( + "https://cap.so/api/mobile/session/request?redirectUri=cap%3A%2F%2Fauth&provider=workos&organizationId=org_123", + ); + }); + + it("updates Cap sharing with the authenticated PATCH endpoint", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + id: "video_123", + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: false, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => "api-key", + }); + const result = await client.updateCapSharing("video_123", { + public: false, + }); + const body = calls[0]?.init?.body; + + expect(result.public).toBe(false); + expect(String(calls[0]?.input)).toBe( + "https://cap.so/api/mobile/caps/video_123/sharing", + ); + expect(calls[0]?.init?.method).toBe("PATCH"); + expect(calls[0]?.init?.headers).toBeInstanceOf(Headers); + expect((calls[0]?.init?.headers as Headers).get("authorization")).toBe( + "Bearer api-key", + ); + expect(typeof body).toBe("string"); + expect(JSON.parse(body as string)).toEqual({ public: false }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("creates folders with the authenticated POST endpoint", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + id: "folder_123", + name: "Product", + color: "blue", + parentId: null, + videoCount: 0, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => "api-key", + }); + const result = await client.createFolder({ + name: "Product", + color: "blue", + }); + const body = calls[0]?.init?.body; + + expect(result.name).toBe("Product"); + expect(String(calls[0]?.input)).toBe("https://cap.so/api/mobile/folders"); + expect(calls[0]?.init?.method).toBe("POST"); + expect(typeof body).toBe("string"); + expect(JSON.parse(body as string)).toEqual({ + name: "Product", + color: "blue", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("updates Cap titles with the authenticated PATCH endpoint", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + id: "video_123", + shareUrl: "https://cap.so/s/video_123", + title: "Roadmap review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => "api-key", + }); + const result = await client.updateCapTitle("video_123", { + title: "Roadmap review", + }); + const body = calls[0]?.init?.body; + + expect(result.title).toBe("Roadmap review"); + expect(String(calls[0]?.input)).toBe( + "https://cap.so/api/mobile/caps/video_123/title", + ); + expect(calls[0]?.init?.method).toBe("PATCH"); + expect(typeof body).toBe("string"); + expect(JSON.parse(body as string)).toEqual({ title: "Roadmap review" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("updates Cap passwords with the authenticated PATCH endpoint", async () => { + const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + calls.push({ input, init }); + return new Response( + JSON.stringify({ + id: "video_123", + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: true, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, + }), + { status: 200 }, + ); + }) as typeof fetch; + + try { + const client = createMobileApiClient({ + baseUrl: "https://cap.so/", + getToken: () => "api-key", + }); + const result = await client.updateCapPassword("video_123", { + password: "secret", + }); + const body = calls[0]?.init?.body; + + expect(result.protected).toBe(true); + expect(String(calls[0]?.input)).toBe( + "https://cap.so/api/mobile/caps/video_123/password", + ); + expect(calls[0]?.init?.method).toBe("PATCH"); + expect(typeof body).toBe("string"); + expect(JSON.parse(body as string)).toEqual({ password: "secret" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("uploads local files with native transfer progress", async () => { + const uploadAsync = vi.fn(() => + Promise.resolve({ + body: "", + headers: {}, + mimeType: null, + status: 200, + }), + ); + const onProgress = vi.fn(); + + fileSystemMock.createUploadTask.mockImplementation( + ( + url: string, + fileUri: string, + options: unknown, + callback?: (data: { + totalBytesExpectedToSend: number; + totalBytesSent: number; + }) => void, + ) => { + callback?.({ + totalBytesExpectedToSend: 3, + totalBytesSent: 2, + }); + return { uploadAsync, url, fileUri, options }; + }, + ); + + await uploadToTarget( + { + type: "driveResumable", + url: "https://uploads.example/drive", + headers: { + "Content-Type": "video/mp4", + }, + }, + { + uri: "file:///tmp/video.mp4", + name: "video.mp4", + type: "video/mp4", + size: 3, + }, + onProgress, + ); + + expect(fileSystemMock.createUploadTask).toHaveBeenCalledWith( + "https://uploads.example/drive", + "file:///tmp/video.mp4", + { + headers: { + "Content-Range": "bytes 0-2/3", + "Content-Type": "video/mp4", + }, + httpMethod: "PUT", + uploadType: fileSystemMock.FileSystemUploadType.BINARY_CONTENT, + }, + expect.any(Function), + ); + expect(uploadAsync).toHaveBeenCalled(); + expect(onProgress).toHaveBeenCalledWith({ loaded: 2, total: 3 }); + }); + + it("sets the Drive resumable upload byte range for remote blobs", async () => { + class MockXMLHttpRequest { + static instances: MockXMLHttpRequest[] = []; + upload: { + onprogress: + | ((event: ProgressEvent) => void) + | null; + } = { onprogress: null }; + status = 200; + responseText = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + method = ""; + url = ""; + headers = new Map(); + body: BodyInit | null = null; + + constructor() { + MockXMLHttpRequest.instances.push(this); + } + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + setRequestHeader(key: string, value: string) { + this.headers.set(key, value); + } + + send(body: BodyInit) { + this.body = body; + this.onload?.(); + } + } + + const originalFetch = globalThis.fetch; + const originalXhr = globalThis.XMLHttpRequest; + globalThis.fetch = (async () => + new Response(new Uint8Array([1, 2, 3]))) as typeof fetch; + globalThis.XMLHttpRequest = + MockXMLHttpRequest as unknown as typeof XMLHttpRequest; + + try { + await uploadToTarget( + { + type: "driveResumable", + url: "https://uploads.example/drive", + headers: { + "Content-Type": "video/mp4", + }, + }, + { + uri: "https://cache.example/video.mp4", + name: "video.mp4", + type: "video/mp4", + size: 3, + }, + ); + + const request = MockXMLHttpRequest.instances[0]; + expect(request?.method).toBe("PUT"); + expect(request?.headers.get("content-type")).toBe("video/mp4"); + expect(request?.headers.get("content-range")).toBe("bytes 0-2/3"); + } finally { + globalThis.fetch = originalFetch; + globalThis.XMLHttpRequest = originalXhr; + } + }); +}); diff --git a/apps/mobile/src/api/mobile.ts b/apps/mobile/src/api/mobile.ts new file mode 100644 index 0000000000..d4d352469a --- /dev/null +++ b/apps/mobile/src/api/mobile.ts @@ -0,0 +1,479 @@ +import { Mobile, type Storage } from "@cap/web-domain"; +import { Schema } from "effect"; +import * as FileSystem from "expo-file-system/legacy"; + +export type MobileApiKeyResponse = typeof Mobile.MobileApiKeyResponse.Type; +export type MobileSuccessResponse = typeof Mobile.MobileSuccessResponse.Type; +export type MobileAuthConfigResponse = + typeof Mobile.MobileAuthConfigResponse.Type; +export type MobileBootstrapResponse = + typeof Mobile.MobileBootstrapResponse.Type; +export type MobileCapsListResponse = typeof Mobile.MobileCapsListResponse.Type; +export type MobileCapSummary = typeof Mobile.MobileCapSummary.Type; +export type MobileFolder = typeof Mobile.MobileFolder.Type; +export type MobileCapDetail = typeof Mobile.MobileCapDetail.Type; +export type MobileComment = typeof Mobile.MobileComment.Type; +export type MobilePlaybackResponse = typeof Mobile.MobilePlaybackResponse.Type; +export type MobileDownloadResponse = typeof Mobile.MobileDownloadResponse.Type; +export type MobileCapSharingInput = typeof Mobile.MobileCapSharingInput.Type; +export type MobileCapTitleInput = typeof Mobile.MobileCapTitleInput.Type; +export type MobileCapPasswordInput = typeof Mobile.MobileCapPasswordInput.Type; +export type MobileFolderCreateInput = + typeof Mobile.MobileFolderCreateInput.Type; +export type MobileUploadCreateInput = + typeof Mobile.MobileUploadCreateInput.Type; +export type MobileUploadCreateResponse = + typeof Mobile.MobileUploadCreateResponse.Type; + +export type MobileApiClient = ReturnType; + +export type UploadFile = { + uri: string; + name: string; + type: string; + size?: number; + durationSeconds?: number; + width?: number; + height?: number; +}; + +export type UploadProgress = { + loaded: number; + total: number; +}; + +type ClientOptions = { + baseUrl: string; + getToken: () => string | Promise | null; +}; + +type RequestOptions = { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + query?: Record; + body?: unknown; +}; + +export class MobileApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly payload: unknown, + ) { + super(message); + this.name = "MobileApiError"; + } +} + +const trimBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, ""); + +const decode = async ( + schema: Schema.Schema, + value: unknown, +): Promise => Schema.decodeUnknownPromise(schema)(value); + +const appendQuery = ( + url: URL, + query: Record | undefined, +) => { + if (!query) return; + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + url.searchParams.set(key, String(value)); + } + } +}; + +const parseJson = async (response: Response) => { + const text = await response.text(); + if (text.length === 0) return null; + return JSON.parse(text) as unknown; +}; + +export const createSessionRequestUrl = ( + baseUrl: string, + redirectUri: string, + provider?: "google" | "workos", + organizationId?: string, +) => { + const url = new URL("/api/mobile/session/request", trimBaseUrl(baseUrl)); + url.searchParams.set("redirectUri", redirectUri); + if (provider) url.searchParams.set("provider", provider); + if (organizationId) url.searchParams.set("organizationId", organizationId); + return url.toString(); +}; + +export const createMobileApiClient = ({ baseUrl, getToken }: ClientOptions) => { + const origin = trimBaseUrl(baseUrl); + + const request = async ( + path: string, + schema: Schema.Schema, + options: RequestOptions = {}, + ): Promise => { + const token = await getToken(); + if (!token) { + throw new MobileApiError("Missing mobile session", 401, null); + } + + const url = new URL(path, origin); + appendQuery(url, options.query); + const headers = new Headers({ + Authorization: `Bearer ${token}`, + }); + let body: BodyInit | undefined; + if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(options.body); + } + + const response = await fetch(url.toString(), { + method: options.method ?? "GET", + headers, + body, + }); + const payload = await parseJson(response); + if (!response.ok) { + throw new MobileApiError( + `Mobile API request failed with ${response.status}`, + response.status, + payload, + ); + } + return decode(schema, payload); + }; + + const publicRequest = async ( + path: string, + schema: Schema.Schema, + options: Omit = {}, + ): Promise => { + const url = new URL(path, origin); + const headers = new Headers(); + let body: BodyInit | undefined; + if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(options.body); + } + + const response = await fetch(url.toString(), { + method: options.method ?? "GET", + headers, + body, + }); + const payload = await parseJson(response); + if (!response.ok) { + throw new MobileApiError( + `Mobile API request failed with ${response.status}`, + response.status, + payload, + ); + } + return decode(schema, payload); + }; + + return { + getAuthConfig: () => + publicRequest( + "/api/mobile/session/config", + Mobile.MobileAuthConfigResponse, + ), + requestEmailCode: (email: string) => + publicRequest( + "/api/mobile/session/email/request", + Mobile.MobileSuccessResponse, + { + method: "POST", + body: { email }, + }, + ), + verifyEmailCode: (input: { email: string; code: string }) => + publicRequest( + "/api/mobile/session/email/verify", + Mobile.MobileApiKeyResponse, + { + method: "POST", + body: input, + }, + ), + bootstrap: () => + request("/api/mobile/bootstrap", Mobile.MobileBootstrapResponse), + setActiveOrganization: (organizationId: string) => + request( + "/api/mobile/user/active-organization", + Mobile.MobileBootstrapResponse, + { + method: "PATCH", + body: { organizationId }, + }, + ), + listCaps: (params: { + folderId?: string | null; + page?: number; + limit?: number; + }) => + request("/api/mobile/caps", Mobile.MobileCapsListResponse, { + query: params, + }), + createFolder: (input: MobileFolderCreateInput) => + request("/api/mobile/folders", Mobile.MobileFolder, { + method: "POST", + body: input, + }), + getCap: (id: string) => + request(`/api/mobile/caps/${id}`, Mobile.MobileCapDetail), + updateCapSharing: (id: string, input: MobileCapSharingInput) => + request(`/api/mobile/caps/${id}/sharing`, Mobile.MobileCapSummary, { + method: "PATCH", + body: input, + }), + updateCapTitle: (id: string, input: MobileCapTitleInput) => + request(`/api/mobile/caps/${id}/title`, Mobile.MobileCapSummary, { + method: "PATCH", + body: input, + }), + updateCapPassword: (id: string, input: MobileCapPasswordInput) => + request(`/api/mobile/caps/${id}/password`, Mobile.MobileCapSummary, { + method: "PATCH", + body: input, + }), + deleteCap: (id: string) => + request(`/api/mobile/caps/${id}`, Mobile.MobileSuccessResponse, { + method: "DELETE", + }), + getPlayback: (id: string) => + request(`/api/mobile/caps/${id}/playback`, Mobile.MobilePlaybackResponse), + getDownload: (id: string) => + request(`/api/mobile/caps/${id}/download`, Mobile.MobileDownloadResponse), + createComment: ( + id: string, + input: { content: string; timestamp: number | null }, + ) => + request(`/api/mobile/caps/${id}/comments`, Mobile.MobileComment, { + method: "POST", + body: input, + }), + deleteComment: (id: string) => + request(`/api/mobile/comments/${id}`, Mobile.MobileSuccessResponse, { + method: "DELETE", + }), + createReaction: ( + id: string, + input: { content: string; timestamp: number | null }, + ) => + request(`/api/mobile/caps/${id}/reactions`, Mobile.MobileComment, { + method: "POST", + body: input, + }), + createUpload: (input: MobileUploadCreateInput) => + request("/api/mobile/uploads", Mobile.MobileUploadCreateResponse, { + method: "POST", + body: input, + }), + updateUploadProgress: ( + id: string, + input: { uploaded: number; total: number }, + ) => + request( + `/api/mobile/uploads/${id}/progress`, + Mobile.MobileSuccessResponse, + { + method: "POST", + body: input, + }, + ), + completeUpload: ( + id: string, + input: { rawFileKey: string; contentLength?: number }, + ) => + request( + `/api/mobile/uploads/${id}/complete`, + Mobile.MobileSuccessResponse, + { + method: "POST", + body: input, + }, + ), + revokeSession: () => + request("/api/mobile/session/revoke", Mobile.MobileSuccessResponse, { + method: "POST", + }), + }; +}; + +const targetHeaders = (headers: Record) => { + const result = new Headers(); + for (const [key, value] of Object.entries(headers)) { + result.set(key, value); + } + return result; +}; + +const isNativeUploadUri = (uri: string) => + uri.startsWith("file://") || uri.startsWith("content://"); + +const getLocalFileSize = async (file: UploadFile) => { + if (typeof file.size === "number" && file.size > 0) return file.size; + + const info = await FileSystem.getInfoAsync(file.uri); + if (!info.exists || info.isDirectory) return 0; + return info.size; +}; + +const uploadNativeFile = async ( + method: "POST" | "PUT", + url: string, + file: UploadFile, + options: FileSystem.FileSystemUploadOptions, + onProgress?: (progress: UploadProgress) => void, +) => { + const task = FileSystem.createUploadTask( + url, + file.uri, + { + ...options, + httpMethod: method, + }, + (data) => { + onProgress?.({ + loaded: data.totalBytesSent, + total: data.totalBytesExpectedToSend, + }); + }, + ); + const response = await task.uploadAsync(); + if (!response || response.status < 200 || response.status >= 300) { + throw new MobileApiError( + "Upload target rejected the file", + response?.status ?? 0, + response?.body ?? null, + ); + } +}; + +const uploadWithXhr = ( + method: "POST" | "PUT", + url: string, + headers: Headers, + body: FormData | Blob, + onProgress?: (progress: UploadProgress) => void, +) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + headers.forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); + xhr.upload.onprogress = (event) => { + onProgress?.({ + loaded: event.loaded, + total: event.lengthComputable ? event.total : 0, + }); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + return; + } + reject( + new MobileApiError( + "Upload target rejected the file", + xhr.status, + xhr.responseText, + ), + ); + }; + xhr.onerror = () => { + reject(new Error("Upload failed")); + }; + xhr.send(body); + }); + +const fileBlob = async (file: UploadFile) => { + const response = await fetch(file.uri); + return response.blob(); +}; + +export const uploadToTarget = async ( + target: Storage.UploadTarget, + file: UploadFile, + onProgress?: (progress: UploadProgress) => void, +) => { + if (target.type === "s3Post") { + if (isNativeUploadUri(file.uri)) { + await uploadNativeFile( + "POST", + target.url, + file, + { + fieldName: "file", + mimeType: file.type, + parameters: target.fields, + uploadType: FileSystem.FileSystemUploadType.MULTIPART, + }, + onProgress, + ); + return; + } + + const formData = new FormData(); + for (const [key, value] of Object.entries(target.fields)) { + formData.append(key, value); + } + formData.append("file", { + uri: file.uri, + name: file.name, + type: file.type, + } as unknown as Blob); + await uploadWithXhr( + "POST", + target.url, + new Headers(), + formData, + onProgress, + ); + return; + } + + const headers = { ...target.headers }; + let size = file.size; + if ( + target.type === "driveResumable" && + typeof size === "number" && + size > 0 + ) { + headers["Content-Range"] = `bytes 0-${size - 1}/${size}`; + } + + if (isNativeUploadUri(file.uri)) { + if (target.type === "driveResumable" && !size) { + size = await getLocalFileSize(file); + if (size > 0) { + headers["Content-Range"] = `bytes 0-${size - 1}/${size}`; + } + } + + await uploadNativeFile( + "PUT", + target.url, + file, + { + headers, + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + }, + onProgress, + ); + return; + } + + const blob = await fileBlob(file); + if (target.type === "driveResumable" && !size && blob.size > 0) { + headers["Content-Range"] = `bytes 0-${blob.size - 1}/${blob.size}`; + } + await uploadWithXhr( + "PUT", + target.url, + targetHeaders(headers), + blob, + onProgress, + ); +}; From b85d8714a47586d9132a66d4003fcefcd72febd8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 11/20] feat(mobile): add upload queue and runner --- apps/mobile/src/uploads/fileTypes.test.ts | 27 ++ apps/mobile/src/uploads/fileTypes.ts | 24 ++ .../src/uploads/runMobileUpload.test.ts | 173 ++++++++++ apps/mobile/src/uploads/runMobileUpload.ts | 71 ++++ apps/mobile/src/uploads/uploadQueue.test.ts | 318 ++++++++++++++++++ apps/mobile/src/uploads/uploadQueue.ts | 201 +++++++++++ 6 files changed, 814 insertions(+) create mode 100644 apps/mobile/src/uploads/fileTypes.test.ts create mode 100644 apps/mobile/src/uploads/fileTypes.ts create mode 100644 apps/mobile/src/uploads/runMobileUpload.test.ts create mode 100644 apps/mobile/src/uploads/runMobileUpload.ts create mode 100644 apps/mobile/src/uploads/uploadQueue.test.ts create mode 100644 apps/mobile/src/uploads/uploadQueue.ts diff --git a/apps/mobile/src/uploads/fileTypes.test.ts b/apps/mobile/src/uploads/fileTypes.test.ts new file mode 100644 index 0000000000..d253cfb319 --- /dev/null +++ b/apps/mobile/src/uploads/fileTypes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { contentTypeForUpload, contentTypeFromName } from "./fileTypes"; + +describe("mobile upload file type inference", () => { + it.each([ + ["demo.mp4", "video/mp4"], + ["demo.mov", "video/quicktime"], + ["demo.webm", "video/webm"], + ["demo.mkv", "video/x-matroska"], + ["demo.avi", "video/x-msvideo"], + ["demo.m4v", "video/x-m4v"], + ])("infers %s as %s", (name, contentType) => { + expect(contentTypeFromName(name)).toBe(contentType); + }); + + it("keeps picker-provided video content types", () => { + expect(contentTypeForUpload("demo.mkv", "video/custom")).toBe( + "video/custom", + ); + }); + + it("falls back to the filename when the picker returns an opaque type", () => { + expect(contentTypeForUpload("demo.mkv", "application/octet-stream")).toBe( + "video/x-matroska", + ); + }); +}); diff --git a/apps/mobile/src/uploads/fileTypes.ts b/apps/mobile/src/uploads/fileTypes.ts new file mode 100644 index 0000000000..b753930561 --- /dev/null +++ b/apps/mobile/src/uploads/fileTypes.ts @@ -0,0 +1,24 @@ +const videoContentTypesByExtension: Record = { + avi: "video/x-msvideo", + m4v: "video/x-m4v", + mkv: "video/x-matroska", + mov: "video/quicktime", + mp4: "video/mp4", + webm: "video/webm", +}; + +const extensionFromName = (name: string) => { + const extension = name.split(".").at(-1)?.toLowerCase(); + return extension && extension !== name.toLowerCase() ? extension : null; +}; + +export const contentTypeFromName = (name: string) => + videoContentTypesByExtension[extensionFromName(name) ?? ""] ?? "video/mp4"; + +export const contentTypeForUpload = ( + name: string, + contentType?: string | null, +) => { + if (contentType?.startsWith("video/")) return contentType; + return contentTypeFromName(name); +}; diff --git a/apps/mobile/src/uploads/runMobileUpload.test.ts b/apps/mobile/src/uploads/runMobileUpload.test.ts new file mode 100644 index 0000000000..3b664f1338 --- /dev/null +++ b/apps/mobile/src/uploads/runMobileUpload.test.ts @@ -0,0 +1,173 @@ +import { Folder, Organisation, Video } from "@cap/web-domain"; +import { describe, expect, it, vi } from "vitest"; +import type { MobileApiClient, UploadFile } from "@/api/mobile"; +import { runMobileUpload } from "./runMobileUpload"; + +const uploadMock = vi.hoisted(() => ({ + uploadToTarget: vi.fn( + async ( + _target: unknown, + _file: UploadFile, + onProgress?: (progress: { loaded: number; total: number }) => void, + ) => { + onProgress?.({ loaded: 40, total: 80 }); + }, + ), +})); + +vi.mock("@/api/mobile", () => ({ + uploadToTarget: uploadMock.uploadToTarget, +})); + +describe("runMobileUpload", () => { + it("passes native video metadata through upload creation and retry-safe progress", async () => { + const createUpload = vi.fn(async () => ({ + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + rawFileKey: "user_123/video_123/raw-upload.mov", + upload: { + type: "put" as const, + url: "https://uploads.example/video", + headers: { + "Content-Type": "video/quicktime", + }, + }, + cap: { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "video", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:00:00.000Z", + ownerName: "Richie", + durationSeconds: 12.5, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, + }, + })); + const updateUploadProgress = vi.fn(async () => ({ + success: true as const, + })); + const completeUpload = vi.fn(async () => ({ success: true as const })); + const client = { + createUpload, + updateUploadProgress, + completeUpload, + } as unknown as MobileApiClient; + const file: UploadFile = { + uri: "file:///tmp/video.mov", + name: "video.mov", + type: "video/quicktime", + size: 80, + durationSeconds: 12.5, + width: 1920, + height: 1080, + }; + const onProgress = vi.fn(); + + await runMobileUpload({ + client, + file, + organizationId: Organisation.OrganisationId.make("org_123"), + folderId: Folder.FolderId.make("folder_123"), + onProgress, + }); + + expect(createUpload).toHaveBeenCalledWith({ + organizationId: "org_123", + folderId: "folder_123", + fileName: "video.mov", + contentType: "video/quicktime", + contentLength: 80, + durationSeconds: 12.5, + width: 1920, + height: 1080, + }); + expect(updateUploadProgress).toHaveBeenCalledWith("video_123", { + uploaded: 40, + total: 80, + }); + expect(completeUpload).toHaveBeenCalledWith("video_123", { + rawFileKey: "user_123/video_123/raw-upload.mov", + contentLength: 80, + }); + expect(onProgress).toHaveBeenCalledWith(0.5); + }); + + it("normalizes non-finite native upload progress", async () => { + uploadMock.uploadToTarget.mockImplementationOnce( + async ( + _target: unknown, + _file: UploadFile, + onProgress?: (progress: { loaded: number; total: number }) => void, + ) => { + onProgress?.({ loaded: Number.NaN, total: Number.NaN }); + }, + ); + const createUpload = vi.fn(async () => ({ + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + rawFileKey: "user_123/video_123/raw-upload.mov", + upload: { + type: "put" as const, + url: "https://uploads.example/video", + headers: { + "Content-Type": "video/quicktime", + }, + }, + cap: { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "video", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:00:00.000Z", + ownerName: "Richie", + durationSeconds: 12.5, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, + }, + })); + const updateUploadProgress = vi.fn(async () => ({ + success: true as const, + })); + const completeUpload = vi.fn(async () => ({ success: true as const })); + const client = { + createUpload, + updateUploadProgress, + completeUpload, + } as unknown as MobileApiClient; + const file: UploadFile = { + uri: "file:///tmp/video.mov", + name: "video.mov", + type: "video/quicktime", + size: 80, + durationSeconds: 12.5, + width: 1920, + height: 1080, + }; + const onProgress = vi.fn(); + + await runMobileUpload({ + client, + file, + onProgress, + }); + + expect(updateUploadProgress).toHaveBeenCalledWith("video_123", { + uploaded: 0, + total: 80, + }); + expect(onProgress).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/mobile/src/uploads/runMobileUpload.ts b/apps/mobile/src/uploads/runMobileUpload.ts new file mode 100644 index 0000000000..a295bc3880 --- /dev/null +++ b/apps/mobile/src/uploads/runMobileUpload.ts @@ -0,0 +1,71 @@ +import { Folder, Organisation } from "@cap/web-domain"; +import type { MobileApiClient, UploadFile } from "@/api/mobile"; +import { uploadToTarget } from "@/api/mobile"; + +type RunMobileUploadInput = { + client: MobileApiClient; + file: UploadFile; + organizationId?: string | null; + folderId?: string | null; + onCreated?: (capId: string, rawFileKey: string) => void; + onProgress?: (progress: number) => void; +}; + +const nonNegativeFiniteNumber = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0; + +const positiveFiniteNumber = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : null; + +const clampProgress = (progress: number) => { + const safeProgress = Number.isFinite(progress) ? progress : 0; + return Math.min(1, Math.max(0, safeProgress)); +}; + +export const runMobileUpload = async ({ + client, + file, + organizationId, + folderId, + onCreated, + onProgress, +}: RunMobileUploadInput) => { + const created = await client.createUpload({ + organizationId: organizationId + ? Organisation.OrganisationId.make(organizationId) + : undefined, + folderId: folderId ? Folder.FolderId.make(folderId) : undefined, + fileName: file.name, + contentType: file.type, + contentLength: file.size, + durationSeconds: file.durationSeconds, + width: file.width, + height: file.height, + }); + onCreated?.(created.id, created.rawFileKey); + + await uploadToTarget(created.upload, file, ({ loaded, total }) => { + const safeLoaded = nonNegativeFiniteNumber(loaded); + const safeTotal = + positiveFiniteNumber(total) ?? + positiveFiniteNumber(file.size) ?? + safeLoaded; + const progress = safeTotal > 0 ? safeLoaded / safeTotal : 0; + onProgress?.(clampProgress(progress)); + client + .updateUploadProgress(created.id, { + uploaded: safeLoaded, + total: safeTotal, + }) + .catch(() => {}); + }); + + await client.completeUpload(created.id, { + rawFileKey: created.rawFileKey, + contentLength: file.size, + }); + + return created; +}; diff --git a/apps/mobile/src/uploads/uploadQueue.test.ts b/apps/mobile/src/uploads/uploadQueue.test.ts new file mode 100644 index 0000000000..aa446cad28 --- /dev/null +++ b/apps/mobile/src/uploads/uploadQueue.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; +import { + emptyUploadQueue, + isTerminalUploadQueueAction, + uploadProgressPercent, + uploadQueueActionFromCapUpload, + uploadQueueReducer, + uploadQueueStatusText, +} from "./uploadQueue"; + +const item = { + id: "local-1", + localUri: "file:///tmp/video.mp4", + fileName: "video.mp4", + contentType: "video/mp4", + size: 100, + folderId: null, + organizationId: "org_123", + status: "queued" as const, + progress: 0, + error: null, + capId: null, + rawFileKey: null, + processingMessage: null, +}; + +describe("uploadQueueReducer", () => { + it("preserves failed uploads for retry", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const failed = uploadQueueReducer(queued, { + type: "fail", + id: item.id, + error: "Network unavailable", + }); + expect(failed.items[0]?.status).toBe("failed"); + expect(failed.items[0]?.error).toBe("Network unavailable"); + + const retrying = uploadQueueReducer(failed, { + type: "retry", + id: item.id, + }); + expect(retrying.items[0]?.status).toBe("queued"); + expect(retrying.items[0]?.error).toBeNull(); + expect(retrying.items[0]?.localUri).toBe(item.localUri); + }); + + it("clears stale server upload metadata before retrying", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const uploading = uploadQueueReducer(queued, { + type: "start", + id: item.id, + capId: "cap_123", + rawFileKey: "raw/video.mp4", + }); + const failed = uploadQueueReducer(uploading, { + type: "fail", + id: item.id, + error: "Upload target rejected the file", + }); + const retrying = uploadQueueReducer(failed, { + type: "retry", + id: item.id, + }); + + expect(retrying.items[0]?.capId).toBeNull(); + expect(retrying.items[0]?.rawFileKey).toBeNull(); + }); + + it("keeps the created Cap id after upload completion", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const uploading = uploadQueueReducer(queued, { + type: "start", + id: item.id, + capId: "cap_123", + rawFileKey: "raw/video.mp4", + }); + const complete = uploadQueueReducer(uploading, { + type: "complete", + id: item.id, + }); + + expect(complete.items[0]).toMatchObject({ + status: "complete", + capId: "cap_123", + rawFileKey: "raw/video.mp4", + }); + }); + + it("uses the web finishing label while processing after upload", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const uploading = uploadQueueReducer(queued, { + type: "start", + id: item.id, + capId: "cap_123", + rawFileKey: "raw/video.mp4", + }); + const processing = uploadQueueReducer(uploading, { + type: "processing", + id: item.id, + progress: 0, + }); + + expect(processing.items[0]).toMatchObject({ + status: "processing", + progress: 0, + capId: "cap_123", + rawFileKey: "raw/video.mp4", + }); + expect( + processing.items[0] ? uploadQueueStatusText(processing.items[0]) : null, + ).toBe("Finishing up"); + }); + + it("uses server processing progress and messages in the queue row", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const processing = uploadQueueReducer(queued, { + type: "processing", + id: item.id, + progress: 0.42, + message: "Processing frames", + }); + + expect(processing.items[0]).toMatchObject({ + status: "processing", + progress: 0.42, + processingMessage: "Processing frames", + }); + expect( + processing.items[0] ? uploadQueueStatusText(processing.items[0]) : null, + ).toBe("Processing frames"); + }); + + it("restores uploading status when progress arrives after processing", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const processing = uploadQueueReducer(queued, { + type: "processing", + id: item.id, + progress: 0.25, + message: "Processing frames", + }); + const uploading = uploadQueueReducer(processing, { + type: "progress", + id: item.id, + progress: 0.5, + }); + + expect(uploading.items[0]).toMatchObject({ + status: "uploading", + progress: 0.5, + error: null, + processingMessage: null, + }); + expect( + uploading.items[0] ? uploadQueueStatusText(uploading.items[0]) : null, + ).toBe("Uploading 50%"); + }); + + it("keeps invalid queue progress display-safe", () => { + const queued = uploadQueueReducer(emptyUploadQueue, { + type: "enqueue", + item, + }); + const invalidUploadProgress = uploadQueueReducer(queued, { + type: "progress", + id: item.id, + progress: Number.NaN, + }); + const invalidProcessingProgress = uploadQueueReducer(queued, { + type: "processing", + id: item.id, + progress: Number.POSITIVE_INFINITY, + message: "Processing frames", + }); + + expect(invalidUploadProgress.items[0]).toMatchObject({ + status: "uploading", + progress: 0, + }); + expect( + invalidUploadProgress.items[0] + ? uploadQueueStatusText(invalidUploadProgress.items[0]) + : null, + ).toBe("Uploading 0%"); + expect(invalidProcessingProgress.items[0]).toMatchObject({ + status: "processing", + progress: 0, + }); + expect(uploadProgressPercent(Number.NaN)).toBe(0); + expect(uploadProgressPercent(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it("maps settled server upload state back to local queue actions", () => { + expect(uploadQueueActionFromCapUpload(item.id, null)).toEqual({ + type: "complete", + id: item.id, + }); + expect( + uploadQueueActionFromCapUpload(item.id, { + uploaded: 100, + total: 100, + phase: "complete", + processingProgress: 100, + processingMessage: null, + processingError: null, + }), + ).toEqual({ + type: "complete", + id: item.id, + }); + expect( + uploadQueueActionFromCapUpload(item.id, { + uploaded: 100, + total: 100, + phase: "error", + processingProgress: 40, + processingMessage: null, + processingError: "Transcode failed", + }), + ).toEqual({ + type: "fail", + id: item.id, + error: "Transcode failed", + }); + }); + + it("maps active server upload state back to local queue progress", () => { + expect( + uploadQueueActionFromCapUpload(item.id, { + uploaded: 25, + total: 100, + phase: "uploading", + processingProgress: 0, + processingMessage: null, + processingError: null, + }), + ).toEqual({ + type: "progress", + id: item.id, + progress: 0.25, + }); + expect( + uploadQueueActionFromCapUpload(item.id, { + uploaded: 100, + total: 100, + phase: "processing", + processingProgress: 42, + processingMessage: "Processing frames", + processingError: null, + }), + ).toEqual({ + type: "processing", + id: item.id, + progress: 0.42, + message: "Processing frames", + }); + expect( + uploadQueueActionFromCapUpload(item.id, { + uploaded: 100, + total: 100, + phase: "generating_thumbnail", + processingProgress: 88, + processingMessage: null, + processingError: null, + }), + ).toEqual({ + type: "processing", + id: item.id, + progress: 0.88, + message: "Finishing up", + }); + }); + + it("keeps polling for non-terminal upload queue actions", () => { + expect(isTerminalUploadQueueAction({ type: "complete", id: item.id })).toBe( + true, + ); + expect( + isTerminalUploadQueueAction({ + type: "fail", + id: item.id, + error: "Transcode failed", + }), + ).toBe(true); + expect( + isTerminalUploadQueueAction({ + type: "progress", + id: item.id, + progress: 0.25, + }), + ).toBe(false); + expect( + isTerminalUploadQueueAction({ + type: "processing", + id: item.id, + progress: 0.42, + message: "Processing frames", + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/uploads/uploadQueue.ts b/apps/mobile/src/uploads/uploadQueue.ts new file mode 100644 index 0000000000..5a3162f759 --- /dev/null +++ b/apps/mobile/src/uploads/uploadQueue.ts @@ -0,0 +1,201 @@ +import type { MobileCapSummary } from "@/api/mobile"; + +export type UploadQueueStatus = + | "queued" + | "uploading" + | "processing" + | "failed" + | "complete"; + +export type UploadQueueItem = { + id: string; + localUri: string; + fileName: string; + contentType: string; + size: number; + durationSeconds?: number; + width?: number; + height?: number; + folderId: string | null; + organizationId: string | null; + status: UploadQueueStatus; + progress: number; + error: string | null; + capId: string | null; + rawFileKey: string | null; + processingMessage: string | null; + createdAt: string; + updatedAt: string; +}; + +export type UploadQueueAction = + | { type: "enqueue"; item: Omit } + | { type: "start"; id: string; capId: string; rawFileKey: string } + | { type: "progress"; id: string; progress: number } + | { + type: "processing"; + id: string; + progress?: number; + message?: string | null; + } + | { type: "complete"; id: string } + | { type: "fail"; id: string; error: string } + | { type: "remove"; id: string } + | { type: "retry"; id: string }; + +export type UploadQueueState = { + items: UploadQueueItem[]; +}; + +const clampProgress = (progress: number) => { + if (!Number.isFinite(progress)) return 0; + return Math.min(1, Math.max(0, progress)); +}; + +export const uploadProgressPercent = (progress: number) => + Math.round(clampProgress(progress) * 100); + +export const isTerminalUploadQueueAction = (action: UploadQueueAction) => + action.type === "complete" || action.type === "fail"; + +export const uploadQueueStatusText = (item: UploadQueueItem) => { + switch (item.status) { + case "queued": + return "Queued"; + case "uploading": + return `Uploading ${uploadProgressPercent(item.progress)}%`; + case "processing": + return item.processingMessage ?? "Finishing up"; + case "complete": + return "Ready to view"; + case "failed": + return "Upload failed"; + } +}; + +export const uploadQueueActionFromCapUpload = ( + id: string, + upload: MobileCapSummary["upload"], +): UploadQueueAction | null => { + if (!upload || upload.phase === "complete") return { type: "complete", id }; + if (upload.phase === "error") { + return { + type: "fail", + id, + error: upload.processingError ?? "Processing failed", + }; + } + if (upload.phase === "uploading") { + return { + type: "progress", + id, + progress: upload.total > 0 ? upload.uploaded / upload.total : 0, + }; + } + return { + type: "processing", + id, + progress: upload.processingProgress / 100, + message: + upload.processingMessage ?? + (upload.phase === "processing" ? "Processing" : "Finishing up"), + }; +}; + +const nowIso = () => new Date().toISOString(); + +const updateItem = ( + state: UploadQueueState, + id: string, + update: (item: UploadQueueItem) => UploadQueueItem, +): UploadQueueState => ({ + items: state.items.map((item) => (item.id === id ? update(item) : item)), +}); + +export const emptyUploadQueue: UploadQueueState = { + items: [], +}; + +export const uploadQueueReducer = ( + state: UploadQueueState, + action: UploadQueueAction, +): UploadQueueState => { + const updatedAt = nowIso(); + + switch (action.type) { + case "enqueue": + return { + items: [ + ...state.items, + { + ...action.item, + createdAt: updatedAt, + updatedAt, + }, + ], + }; + case "start": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "uploading", + progress: 0, + error: null, + capId: action.capId, + rawFileKey: action.rawFileKey, + processingMessage: null, + updatedAt, + })); + case "progress": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "uploading", + progress: clampProgress(action.progress), + error: null, + processingMessage: null, + updatedAt, + })); + case "processing": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "processing", + progress: + action.progress !== undefined + ? clampProgress(action.progress) + : item.progress, + processingMessage: action.message ?? null, + updatedAt, + })); + case "complete": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "complete", + progress: 1, + error: null, + processingMessage: null, + updatedAt, + })); + case "fail": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "failed", + error: action.error, + processingMessage: null, + updatedAt, + })); + case "remove": + return { + items: state.items.filter((item) => item.id !== action.id), + }; + case "retry": + return updateItem(state, action.id, (item) => ({ + ...item, + status: "queued", + progress: 0, + error: null, + capId: null, + rawFileKey: null, + processingMessage: null, + updatedAt, + })); + } +}; From 0479e9abbb1008ee6b7f8acb717c6d87f32035c8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 12/20] feat(mobile): add cap settings and metadata actions --- .../mobile/src/caps/CapSettingsSheet.test.tsx | 319 ++++++++++++ apps/mobile/src/caps/CapSettingsSheet.tsx | 471 ++++++++++++++++++ apps/mobile/src/caps/passwordActions.test.ts | 116 +++++ apps/mobile/src/caps/passwordActions.ts | 97 ++++ apps/mobile/src/caps/saveCapVideo.ts | 30 ++ apps/mobile/src/caps/titleActions.test.ts | 93 ++++ apps/mobile/src/caps/titleActions.ts | 53 ++ 7 files changed, 1179 insertions(+) create mode 100644 apps/mobile/src/caps/CapSettingsSheet.test.tsx create mode 100644 apps/mobile/src/caps/CapSettingsSheet.tsx create mode 100644 apps/mobile/src/caps/passwordActions.test.ts create mode 100644 apps/mobile/src/caps/passwordActions.ts create mode 100644 apps/mobile/src/caps/saveCapVideo.ts create mode 100644 apps/mobile/src/caps/titleActions.test.ts create mode 100644 apps/mobile/src/caps/titleActions.ts diff --git a/apps/mobile/src/caps/CapSettingsSheet.test.tsx b/apps/mobile/src/caps/CapSettingsSheet.test.tsx new file mode 100644 index 0000000000..535558bc5a --- /dev/null +++ b/apps/mobile/src/caps/CapSettingsSheet.test.tsx @@ -0,0 +1,319 @@ +import { Video } from "@cap/web-domain"; +import type { ReactElement, ReactNode } from "react"; +import { Switch } from "react-native"; +import TestRenderer, { + act, + type ReactTestInstance, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import type { MobileCapSummary } from "@/api/mobile"; +import { CapSettingsSheet } from "./CapSettingsSheet"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const getInstanceText = (node: ReactTestInstance): string[] => + node.children.flatMap((child) => + typeof child === "string" ? [child] : getInstanceText(child), + ); + +const getNodeType = (node: ReactTestInstance) => String(node.type); +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = typeof style === "function" ? style({ pressed }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + Modal: createHost("Modal"), + Pressable: createHost("Pressable"), + ScrollView: createHost("ScrollView"), + StyleSheet: { + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Switch: createHost("Switch"), + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +vi.mock("@/components/GlassSurface", async () => { + const React = await import("react"); + return { + GlassSurface: ({ children, ...props }: HostProps) => + React.createElement("GlassSurface", props, children), + }; +}); + +const cap: MobileCapSummary = { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: true, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, +}; + +describe("CapSettingsSheet", () => { + it("renders native settings rows for Cap actions", async () => { + const renderer = await renderComponent( + , + ); + + expect(getTextNodes(renderer.toJSON())).toEqual( + expect.arrayContaining([ + "Settings", + "Launch review", + "Title", + "View analytics", + "Public link", + "Password", + "Protected", + "Copy link", + "Share", + "Save video", + "Delete Cap", + ]), + ); + expect( + renderer.root.findAll((node) => getNodeType(node) === "GlassSurface"), + ).toHaveLength(4); + expect( + renderer.root.find((node) => getNodeType(node) === "Modal").props + .allowSwipeDismissal, + ).toBe(true); + const closeButton = renderer.root.findByProps({ + accessibilityLabel: "Close Cap settings", + }); + expect(closeButton.props.accessibilityHint).toBe("Dismisses Cap settings"); + expect(closeButton.props.hitSlop).toBe(8); + expect( + renderer.root.findByProps({ accessibilityHint: "Renames this Cap" }), + ).toBeTruthy(); + expect( + renderer.root.findByProps({ + accessibilityHint: "Copies this Cap link", + }), + ).toBeTruthy(); + expect( + renderer.root.findByProps({ + accessibilityHint: "Opens the native share sheet", + }), + ).toBeTruthy(); + expect( + renderer.root.findByProps({ accessibilityHint: "Deletes this Cap" }), + ).toBeTruthy(); + }); + + it("updates public link with the native switch", async () => { + const onVisibilityChange = vi.fn(); + const renderer = await renderComponent( + , + ); + + const switchNode = renderer.root.findByType(Switch); + expect(switchNode.props).toMatchObject({ + accessibilityLabel: "Public link", + accessibilityHint: "Toggles public link sharing", + accessibilityRole: "switch", + accessibilityState: { + checked: true, + disabled: false, + }, + ios_backgroundColor: "#e0e0e0", + trackColor: { + false: "#e0e0e0", + true: "#8ec8f6", + }, + }); + + switchNode.props.onValueChange(false); + + expect(onVisibilityChange).toHaveBeenCalledWith(cap, false); + }); + + it("marks disabled save actions as unavailable in the native settings sheet", async () => { + const onSaveVideo = vi.fn(); + const renderer = await renderComponent( + , + ); + + const saveRow = renderer.root + .findAllByProps({ accessibilityRole: "button" }) + .find((node) => getInstanceText(node).includes("Save video")); + if (!saveRow) throw new Error("Save video row was not rendered"); + + expect(saveRow.props.accessibilityState).toEqual({ disabled: true }); + expect(saveRow.props.disabled).toBe(true); + expect(saveRow.props.accessibilityHint).toBe("Save is in progress"); + expect(saveRow.props.accessibilityValue).toEqual({ + text: "Saving video for Launch review", + }); + expect(getInstanceText(saveRow)).not.toContain("Saving..."); + expect(resolveStyle(saveRow.props.style)).toMatchObject({ + backgroundColor: "#f9f9f9", + }); + }); + + it("marks disabled sharing updates as in progress", async () => { + const onVisibilityChange = vi.fn(); + const renderer = await renderComponent( + , + ); + + const publicLinkRow = renderer.root + .findAllByProps({ accessibilityLabel: "Public link" }) + .find((node) => getInstanceText(node).includes("Public link")); + if (!publicLinkRow) throw new Error("Public link row was not rendered"); + const switchNode = renderer.root.findByType(Switch); + expect(publicLinkRow.props.accessibilityValue).toEqual({ + text: "Updating sharing for Launch review", + }); + expect(getInstanceText(publicLinkRow)).not.toContain("Updating..."); + expect(switchNode.props.accessibilityState).toEqual({ + checked: true, + disabled: true, + }); + expect(switchNode.props.accessibilityHint).toBe( + "Sharing update is in progress", + ); + expect(switchNode.props.disabled).toBe(true); + }); + + it("opens analytics from the native settings sheet", async () => { + const onViewAnalytics = vi.fn(); + const renderer = await renderComponent( + , + ); + + const analyticsRow = renderer.root + .findAllByProps({ accessibilityRole: "button" }) + .find((node) => getInstanceText(node).includes("View analytics")); + if (!analyticsRow) throw new Error("Analytics row was not rendered"); + expect(analyticsRow.props.accessibilityHint).toBe( + "Opens analytics in a browser sheet", + ); + + await act(async () => { + analyticsRow.props.onPress(); + }); + + expect(onViewAnalytics).toHaveBeenCalledWith(cap); + }); +}); diff --git a/apps/mobile/src/caps/CapSettingsSheet.tsx b/apps/mobile/src/caps/CapSettingsSheet.tsx new file mode 100644 index 0000000000..ceb1ab03ba --- /dev/null +++ b/apps/mobile/src/caps/CapSettingsSheet.tsx @@ -0,0 +1,471 @@ +import { type SFSymbol, SymbolView } from "expo-symbols"; +import type { ReactNode } from "react"; +import { + Modal, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + View, +} from "react-native"; +import type { MobileCapSummary } from "@/api/mobile"; +import { GlassSurface } from "@/components/GlassSurface"; +import { colors, fonts, radius, squircle } from "@/theme"; + +type CapSettingsSheetProps = { + cap: MobileCapSummary | null; + visible: boolean; + onClose: () => void; + onCopyLink: (cap: MobileCapSummary) => void; + onShareLink: (cap: MobileCapSummary) => void; + onRename: (cap: MobileCapSummary) => void; + onPassword: (cap: MobileCapSummary) => void; + onViewAnalytics?: (cap: MobileCapSummary) => void; + onVisibilityChange: (cap: MobileCapSummary, isPublic: boolean) => void; + onSaveVideo: (cap: MobileCapSummary) => void; + onDelete: (cap: MobileCapSummary) => void; + visibilityDisabled?: boolean; + visibilityDisabledHint?: string; + visibilityDisabledValue?: string; + visibilityDisabledAccessibilityValue?: string; + saveDisabled?: boolean; + saveDisabledHint?: string; + saveDisabledValue?: string; + saveDisabledAccessibilityValue?: string; +}; + +type SettingsRowProps = { + label: string; + value?: string; + accessibilityValueText?: string; + symbol: SFSymbol; + accessibilityHint?: string; + danger?: boolean; + disabled?: boolean; + onPress?: () => void; + children?: ReactNode; +}; + +function SettingsRow({ + label, + value, + accessibilityValueText, + symbol, + accessibilityHint, + danger = false, + disabled = false, + onPress, + children, +}: SettingsRowProps) { + const accessibilityValue = accessibilityValueText + ? { text: accessibilityValueText } + : value + ? { text: value } + : undefined; + const isAction = Boolean(onPress) || disabled; + const content = ( + <> + + + + + {label} + + {value ? ( + + {value} + + ) : null} + {children} + {isAction ? ( + + ) : null} + + ); + + if (!isAction) { + return ( + + {content} + + ); + } + + return ( + [ + styles.row, + pressed && !disabled ? styles.rowPressed : null, + disabled ? styles.rowDisabled : null, + ]} + > + {content} + + ); +} + +function SettingsSection({ + children, + title, +}: { + children: ReactNode; + title: string; +}) { + return ( + + {title} + + {children} + + + ); +} + +export function CapSettingsSheet({ + cap, + visible, + onClose, + onCopyLink, + onShareLink, + onRename, + onPassword, + onViewAnalytics, + onVisibilityChange, + onSaveVideo, + onDelete, + visibilityDisabled = false, + visibilityDisabledHint, + visibilityDisabledValue, + visibilityDisabledAccessibilityValue, + saveDisabled = false, + saveDisabledHint, + saveDisabledValue, + saveDisabledAccessibilityValue, +}: CapSettingsSheetProps) { + if (!cap) return null; + + return ( + + + + + Settings + + {cap.title} + + + {cap.shareUrl} + + + [ + styles.closeButton, + pressed ? styles.closeButtonPressed : null, + ]} + > + + + + + + onRename(cap)} + symbol="textformat" + value={cap.title} + /> + {onViewAnalytics ? ( + <> + + onViewAnalytics(cap)} + symbol="chart.bar" + /> + + ) : null} + + + + + onVisibilityChange(cap, value)} + trackColor={{ false: colors.gray5, true: colors.blue7 }} + thumbColor={colors.white} + value={cap.public} + /> + + + onPassword(cap)} + symbol={cap.protected ? "lock.fill" : "lock.open"} + value={cap.protected ? "Protected" : "Off"} + /> + + + + onCopyLink(cap)} + symbol="doc.on.doc" + /> + + onShareLink(cap)} + symbol="square.and.arrow.up" + /> + + onSaveVideo(cap)} + symbol="square.and.arrow.down" + accessibilityValueText={saveDisabledAccessibilityValue} + value={saveDisabled ? saveDisabledValue : undefined} + /> + + + + onDelete(cap)} + symbol="trash" + /> + + + + ); +} + +const styles = StyleSheet.create({ + sheet: { + flex: 1, + backgroundColor: colors.appBackground, + }, + sheetContent: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 28, + }, + header: { + flexDirection: "row", + alignItems: "flex-start", + gap: 16, + paddingTop: 8, + paddingBottom: 18, + }, + headerCopy: { + flex: 1, + minWidth: 0, + }, + eyebrow: { + fontFamily: fonts.medium, + fontSize: 13, + lineHeight: 18, + color: colors.gray10, + marginBottom: 4, + }, + title: { + fontFamily: fonts.medium, + fontSize: 24, + lineHeight: 30, + color: colors.gray12, + }, + shareUrl: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + marginTop: 4, + }, + closeButton: { + width: 34, + height: 34, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray5, + ...squircle, + }, + closeButtonPressed: { + backgroundColor: colors.gray5, + }, + section: { + gap: 8, + marginBottom: 18, + }, + sectionTitle: { + fontFamily: fonts.medium, + fontSize: 13, + lineHeight: 18, + color: colors.gray10, + paddingHorizontal: 4, + }, + group: { + overflow: "hidden", + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + ...squircle, + }, + groupFallback: { + backgroundColor: colors.gray1, + }, + row: { + minHeight: 54, + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 14, + paddingVertical: 10, + }, + rowPressed: { + backgroundColor: colors.gray2, + }, + rowDisabled: { + backgroundColor: colors.gray2, + }, + rowIcon: { + width: 28, + height: 28, + borderRadius: radius.sm, + backgroundColor: colors.gray3, + alignItems: "center", + justifyContent: "center", + ...squircle, + }, + dangerIcon: { + backgroundColor: colors.red3, + }, + rowIconDisabled: { + backgroundColor: colors.gray3, + }, + rowLabel: { + flex: 1, + fontFamily: fonts.regular, + fontSize: 16, + lineHeight: 22, + color: colors.gray12, + }, + rowValue: { + maxWidth: "42%", + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + }, + rowLabelDisabled: { + color: colors.gray9, + }, + rowValueDisabled: { + color: colors.gray9, + }, + dangerText: { + color: colors.red11, + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.gray3, + marginLeft: 54, + }, +}); diff --git a/apps/mobile/src/caps/passwordActions.test.ts b/apps/mobile/src/caps/passwordActions.test.ts new file mode 100644 index 0000000000..95d0511aae --- /dev/null +++ b/apps/mobile/src/caps/passwordActions.test.ts @@ -0,0 +1,116 @@ +import { Video } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MobileApiClient, MobileCapSummary } from "@/api/mobile"; +import { showCapPasswordActions } from "./passwordActions"; + +const reactNativeMock = vi.hoisted(() => ({ + ActionSheetIOS: { + showActionSheetWithOptions: vi.fn(), + }, + Alert: { + alert: vi.fn(), + prompt: vi.fn(), + }, + Platform: { + OS: "ios", + }, + StyleSheet: { + create: >(styles: T) => styles, + }, +})); + +vi.mock("react-native", () => reactNativeMock); + +const cap: MobileCapSummary = { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, +}; + +describe("showCapPasswordActions", () => { + beforeEach(() => { + reactNativeMock.ActionSheetIOS.showActionSheetWithOptions.mockClear(); + reactNativeMock.Alert.alert.mockClear(); + reactNativeMock.Alert.prompt.mockClear(); + }); + + it("uses a native secure prompt to add a Cap password", async () => { + const updated = { ...cap, protected: true }; + const updateCapPassword = vi.fn(async () => updated); + const onUpdated = vi.fn(); + + showCapPasswordActions({ + cap, + client: { updateCapPassword } as unknown as MobileApiClient, + onUpdated, + }); + + expect(reactNativeMock.Alert.prompt).toHaveBeenCalledWith( + "Add password", + "Set a password for this Cap link.", + expect.any(Array), + "secure-text", + ); + + const buttons = reactNativeMock.Alert.prompt.mock.calls[0]?.[2]; + const saveButton = Array.isArray(buttons) ? buttons[1] : undefined; + saveButton?.onPress?.(" secret "); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateCapPassword).toHaveBeenCalledWith("video_123", { + password: "secret", + }); + expect(onUpdated).toHaveBeenCalledWith(updated); + }); + + it("uses a native action sheet to remove an existing password", async () => { + const protectedCap = { ...cap, protected: true }; + const updated = { ...protectedCap, protected: false }; + const updateCapPassword = vi.fn(async () => updated); + const onUpdated = vi.fn(); + + showCapPasswordActions({ + cap: protectedCap, + client: { updateCapPassword } as unknown as MobileApiClient, + onUpdated, + }); + + expect( + reactNativeMock.ActionSheetIOS.showActionSheetWithOptions, + ).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + options: ["Change password", "Remove password", "Cancel"], + title: "Password protected", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + + const callback = + reactNativeMock.ActionSheetIOS.showActionSheetWithOptions.mock + .calls[0]?.[1]; + callback?.(1); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateCapPassword).toHaveBeenCalledWith("video_123", { + password: null, + }); + expect(onUpdated).toHaveBeenCalledWith(updated); + }); +}); diff --git a/apps/mobile/src/caps/passwordActions.ts b/apps/mobile/src/caps/passwordActions.ts new file mode 100644 index 0000000000..62eb4e0f81 --- /dev/null +++ b/apps/mobile/src/caps/passwordActions.ts @@ -0,0 +1,97 @@ +import { + ActionSheetIOS, + Alert, + type AlertButton, + Platform, +} from "react-native"; +import type { MobileApiClient, MobileCapSummary } from "@/api/mobile"; +import { colors } from "@/theme"; + +type CapPasswordActionsInput = { + cap: MobileCapSummary; + client: MobileApiClient; + onUpdated: (cap: MobileCapSummary) => void | Promise; +}; + +const getPasswordErrorMessage = (error: unknown) => + error instanceof Error ? error.message : "Unable to update this password."; + +const savePassword = async ({ + cap, + client, + onUpdated, + password, +}: CapPasswordActionsInput & { password: string | null }) => { + try { + const updated = await client.updateCapPassword(cap.id, { password }); + await onUpdated(updated); + } catch (error) { + Alert.alert("Password update failed", getPasswordErrorMessage(error)); + } +}; + +const promptForPassword = (input: CapPasswordActionsInput) => { + const title = input.cap.protected ? "Change password" : "Add password"; + + if (Platform.OS !== "ios") { + Alert.alert("Password", "Password editing is available on iOS."); + return; + } + + Alert.prompt( + title, + "Set a password for this Cap link.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Save", + onPress: (value?: string) => { + const password = value?.trim() ?? ""; + if (!password) { + Alert.alert("Password required", "Enter a password for this Cap."); + return; + } + void savePassword({ ...input, password }); + }, + }, + ], + "secure-text", + ); +}; + +export const showCapPasswordActions = (input: CapPasswordActionsInput) => { + if (!input.cap.protected) { + promptForPassword(input); + return; + } + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + options: ["Change password", "Remove password", "Cancel"], + title: "Password protected", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) promptForPassword(input); + if (index === 1) void savePassword({ ...input, password: null }); + }, + ); + return; + } + + const buttons: AlertButton[] = [ + { + text: "Remove password", + style: "destructive", + onPress: () => { + void savePassword({ ...input, password: null }); + }, + }, + { text: "Cancel", style: "cancel" }, + ]; + Alert.alert("Password protected", undefined, buttons); +}; diff --git a/apps/mobile/src/caps/saveCapVideo.ts b/apps/mobile/src/caps/saveCapVideo.ts new file mode 100644 index 0000000000..dc52b4a424 --- /dev/null +++ b/apps/mobile/src/caps/saveCapVideo.ts @@ -0,0 +1,30 @@ +import * as FileSystem from "expo-file-system/legacy"; +import * as MediaLibrary from "expo-media-library"; +import type { MobileApiClient } from "@/api/mobile"; + +export class PhotosPermissionDeniedError extends Error { + constructor() { + super("Photos access needed"); + this.name = "PhotosPermissionDeniedError"; + } +} + +const safeFileName = (fileName: string) => + fileName.replace(/[^\w.\- ]+/g, "").trim() || "Cap.mp4"; + +export const saveCapVideoToPhotos = async ( + client: MobileApiClient, + capId: string, +) => { + const permission = await MediaLibrary.requestPermissionsAsync(); + if (!permission.granted) throw new PhotosPermissionDeniedError(); + + const download = await client.getDownload(capId); + const target = `${FileSystem.documentDirectory}${safeFileName( + download.fileName, + )}`; + const result = await FileSystem.downloadAsync(download.url, target); + await MediaLibrary.saveToLibraryAsync(result.uri); + + return download.fileName; +}; diff --git a/apps/mobile/src/caps/titleActions.test.ts b/apps/mobile/src/caps/titleActions.test.ts new file mode 100644 index 0000000000..0d9622a5a3 --- /dev/null +++ b/apps/mobile/src/caps/titleActions.test.ts @@ -0,0 +1,93 @@ +import { Video } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MobileApiClient, MobileCapSummary } from "@/api/mobile"; +import { showCapTitleActions } from "./titleActions"; + +const reactNativeMock = vi.hoisted(() => ({ + Alert: { + alert: vi.fn(), + prompt: vi.fn(), + }, + Platform: { + OS: "ios", + }, +})); + +vi.mock("react-native", () => reactNativeMock); + +const cap: MobileCapSummary = { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: null, + thumbnailUrl: null, + folderId: null, + public: true, + protected: false, + viewCount: 0, + commentCount: 0, + reactionCount: 0, + upload: null, +}; + +describe("showCapTitleActions", () => { + beforeEach(() => { + reactNativeMock.Alert.alert.mockClear(); + reactNativeMock.Alert.prompt.mockClear(); + reactNativeMock.Platform.OS = "ios"; + }); + + it("uses a native prompt to rename a Cap", async () => { + const updated = { ...cap, title: "Roadmap review" }; + const updateCapTitle = vi.fn(async () => updated); + const onUpdated = vi.fn(); + + showCapTitleActions({ + cap, + client: { updateCapTitle } as unknown as MobileApiClient, + onUpdated, + }); + + expect(reactNativeMock.Alert.prompt).toHaveBeenCalledWith( + "Rename Cap", + undefined, + expect.any(Array), + "plain-text", + "Launch review", + ); + + const buttons = reactNativeMock.Alert.prompt.mock.calls[0]?.[2]; + const saveButton = Array.isArray(buttons) ? buttons[1] : undefined; + saveButton?.onPress?.(" Roadmap review "); + await Promise.resolve(); + await Promise.resolve(); + + expect(updateCapTitle).toHaveBeenCalledWith("video_123", { + title: "Roadmap review", + }); + expect(onUpdated).toHaveBeenCalledWith(updated); + }); + + it("rejects blank Cap titles before calling the API", () => { + const updateCapTitle = vi.fn(); + + showCapTitleActions({ + cap, + client: { updateCapTitle } as unknown as MobileApiClient, + onUpdated: vi.fn(), + }); + + const buttons = reactNativeMock.Alert.prompt.mock.calls[0]?.[2]; + const saveButton = Array.isArray(buttons) ? buttons[1] : undefined; + saveButton?.onPress?.(" "); + + expect(updateCapTitle).not.toHaveBeenCalled(); + expect(reactNativeMock.Alert.alert).toHaveBeenCalledWith( + "Title required", + "Enter a title for this Cap.", + ); + }); +}); diff --git a/apps/mobile/src/caps/titleActions.ts b/apps/mobile/src/caps/titleActions.ts new file mode 100644 index 0000000000..245243a836 --- /dev/null +++ b/apps/mobile/src/caps/titleActions.ts @@ -0,0 +1,53 @@ +import { Alert, Platform } from "react-native"; +import type { MobileApiClient, MobileCapSummary } from "@/api/mobile"; + +type CapTitleActionsInput = { + cap: MobileCapSummary; + client: MobileApiClient; + onUpdated: (cap: MobileCapSummary) => void | Promise; +}; + +const getTitleErrorMessage = (error: unknown) => + error instanceof Error ? error.message : "Unable to rename this Cap."; + +const saveTitle = async ({ + cap, + client, + onUpdated, + title, +}: CapTitleActionsInput & { title: string }) => { + try { + const updated = await client.updateCapTitle(cap.id, { title }); + await onUpdated(updated); + } catch (error) { + Alert.alert("Rename failed", getTitleErrorMessage(error)); + } +}; + +export const showCapTitleActions = (input: CapTitleActionsInput) => { + if (Platform.OS !== "ios") { + Alert.alert("Rename Cap", "Title editing is available on iOS."); + return; + } + + Alert.prompt( + "Rename Cap", + undefined, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Save", + onPress: (value?: string) => { + const title = value?.trim() ?? ""; + if (!title) { + Alert.alert("Title required", "Enter a title for this Cap."); + return; + } + void saveTitle({ ...input, title }); + }, + }, + ], + "plain-text", + input.cap.title, + ); +}; From c586c5834d52f46d7a33e0a130aea8bca2790bd2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 13/20] feat(mobile): add root layout and tab navigation --- apps/mobile/app/(tabs)/_layout.tsx | 53 ++++++++++++++ apps/mobile/app/_layout.tsx | 110 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/mobile/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/app/_layout.tsx diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000000..3985c71fe9 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,53 @@ +import { NativeTabs } from "expo-router/unstable-native-tabs"; +import { colors, fonts } from "@/theme"; + +export default function TabsLayout() { + return ( + + + My Caps + + + + Import + + + + Account + + + + ); +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000000..b5794d72fa --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,110 @@ +import "react-native-gesture-handler"; +import "react-native-reanimated"; + +import { useFonts } from "expo-font"; +import { Stack, useSegments } from "expo-router"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, + StatusBar, + StyleSheet, + View, +} from "react-native"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; +import { AuthProvider, useAuth } from "@/auth/AuthContext"; +import { SignInPanel } from "@/auth/SignInPanel"; +import { signInTitleForSegments } from "@/auth/signInDestination"; +import { colors } from "@/theme"; + +function AppShell() { + const auth = useAuth(); + const segments = useSegments(); + + if (auth.status === "loading") { + return ( + + + + ); + } + + if (auth.status === "signedOut") { + return ( + + + + + + + + ); + } + + return ( + + + + + ); +} + +export default function RootLayout() { + const [fontsLoaded] = useFonts({ + "NeueMontreal-Regular": require("../../web/public/fonts/NeueMontreal-Regular.otf"), + "NeueMontreal-Medium": require("../../web/public/fonts/NeueMontreal-Medium.otf"), + "NeueMontreal-Bold": require("../../web/public/fonts/NeueMontreal-Bold.otf"), + }); + + if (!fontsLoaded) return null; + + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + loadingScreen: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.appBackground, + }, + authScreen: { + flex: 1, + backgroundColor: colors.appBackground, + }, + authKeyboard: { + flex: 1, + }, + authScroll: { + flex: 1, + }, + authContent: { + flexGrow: 1, + justifyContent: "center", + paddingHorizontal: 20, + paddingVertical: 28, + }, +}); From 315ec317801c8474e298fdc52f82b053daad63e1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 14/20] feat(mobile): add dashboard tab screen --- apps/mobile/app/(tabs)/index.tsx | 947 +++++++ .../dashboard-upload-visibility.test.tsx | 2377 +++++++++++++++++ 2 files changed, 3324 insertions(+) create mode 100644 apps/mobile/app/(tabs)/index.tsx create mode 100644 apps/mobile/src/screens/dashboard-upload-visibility.test.tsx diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000000..817f7e3c7f --- /dev/null +++ b/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,947 @@ +import { FlashList } from "@shopify/flash-list"; +import * as Clipboard from "expo-clipboard"; +import { router } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import * as WebBrowser from "expo-web-browser"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActionSheetIOS, + Alert, + Linking, + Platform, + Pressable, + Share, + StyleSheet, + Text, + View, +} from "react-native"; +import type { + MobileCapSummary, + MobileCapsListResponse, + MobileFolder, +} from "@/api/mobile"; +import { MobileApiError } from "@/api/mobile"; +import { apiBaseUrl, useAuth } from "@/auth/AuthContext"; +import { SignInPanel } from "@/auth/SignInPanel"; +import { CapSettingsSheet } from "@/caps/CapSettingsSheet"; +import { showCapPasswordActions } from "@/caps/passwordActions"; +import { + PhotosPermissionDeniedError, + saveCapVideoToPhotos, +} from "@/caps/saveCapVideo"; +import { showCapTitleActions } from "@/caps/titleActions"; +import { ActionButton } from "@/components/ActionButton"; +import { CapCard } from "@/components/CapCard"; +import { CapLogoBadge } from "@/components/CapLogoBadge"; +import { CapRefreshControl } from "@/components/CapRefreshControl"; +import { OrgSwitcher } from "@/components/OrgSwitcher"; +import { Screen } from "@/components/Screen"; +import { colors, fonts, radius, squircle } from "@/theme"; + +type ListItem = + | { type: "section"; id: "folders" | "videos"; title: string } + | { type: "folder"; folder: MobileFolder } + | { type: "cap"; cap: MobileCapSummary }; + +const folderColorOptions: Array<{ + label: string; + color: MobileFolder["color"]; +}> = [ + { label: "Normal", color: "normal" }, + { label: "Blue", color: "blue" }, + { label: "Red", color: "red" }, + { label: "Yellow", color: "yellow" }, +]; + +const folderTintByColor = { + normal: colors.gray12, + blue: colors.blue9, + red: colors.red9, + yellow: colors.yellow9, +} as const; + +const getCapsErrorMessage = (error: unknown) => { + if (error instanceof MobileApiError) { + if (error.status === 401) return "Your session expired. Sign in again."; + return "Cap could not load your library. Try again."; + } + return error instanceof Error + ? error.message + : "Cap could not load your library. Try again."; +}; + +const showPhotosSettingsAlert = () => { + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + message: "Allow Cap to save videos to Photos from Settings.", + options: ["Open Settings", "Cancel"], + title: "Photos access needed", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) void Linking.openSettings(); + }, + ); + return; + } + + Alert.alert( + "Photos access needed", + "Allow Cap to save videos to Photos from Settings.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => { + void Linking.openSettings(); + }, + }, + ], + ); +}; + +export default function CapsScreen() { + const auth = useAuth(); + const [folder, setFolder] = useState(null); + const [result, setResult] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [savingId, setSavingId] = useState(null); + const [updatingSharingId, setUpdatingSharingId] = useState( + null, + ); + const [settingsCap, setSettingsCap] = useState(null); + const [creatingFolder, setCreatingFolder] = useState(false); + const [creatingFolderName, setCreatingFolderName] = useState( + null, + ); + + const load = useCallback(async () => { + if (auth.status !== "signedIn") return; + setLoading(true); + try { + const response = await auth.client.listCaps({ + folderId: folder?.id ?? null, + page: 1, + limit: 30, + }); + setResult(response); + setLoadError(null); + } catch (error) { + setLoadError(getCapsErrorMessage(error)); + } finally { + setLoading(false); + } + }, [auth, folder?.id]); + + useEffect(() => { + void load(); + }, [load]); + + const refresh = useCallback(async () => { + setRefreshing(true); + try { + await Promise.all([auth.refresh(), load()]); + } catch (error) { + setLoadError(getCapsErrorMessage(error)); + } finally { + setRefreshing(false); + } + }, [auth, load]); + + const confirmDeleteCap = useCallback( + (cap: MobileCapSummary) => { + if (auth.status !== "signedIn") return; + const deleteCap = () => { + void (async () => { + setSettingsCap(null); + await auth.client.deleteCap(cap.id); + await Promise.all([auth.refresh(), load()]); + })(); + }; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + destructiveButtonIndex: 0, + message: `${cap.title} will be removed from your library.`, + options: ["Delete Cap", "Cancel"], + title: "Delete Cap", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) deleteCap(); + }, + ); + return; + } + + Alert.alert( + "Delete Cap", + `${cap.title} will be removed from your library.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: deleteCap, + }, + ], + ); + }, + [auth, load], + ); + + const copyCapLink = useCallback((cap: MobileCapSummary) => { + void Clipboard.setStringAsync(cap.shareUrl); + }, []); + + const shareCapLink = useCallback((cap: MobileCapSummary) => { + void Share.share({ url: cap.shareUrl, message: cap.shareUrl }); + }, []); + + const updateCapVisibility = useCallback( + async (cap: MobileCapSummary, isPublic: boolean) => { + if (auth.status !== "signedIn" || updatingSharingId !== null) return; + setUpdatingSharingId(cap.id); + try { + const updated = await auth.client.updateCapSharing(cap.id, { + public: isPublic, + }); + setSettingsCap((current) => + current?.id === updated.id ? updated : current, + ); + await Promise.all([auth.refresh(), load()]); + } catch (error) { + Alert.alert( + "Sharing update failed", + error instanceof Error + ? error.message + : "Unable to update sharing for this Cap.", + ); + } finally { + setUpdatingSharingId(null); + } + }, + [auth, load, updatingSharingId], + ); + + const saveCapVideo = useCallback( + async (cap: MobileCapSummary) => { + if (auth.status !== "signedIn" || savingId !== null) return; + setSavingId(cap.id); + try { + await saveCapVideoToPhotos(auth.client, cap.id); + } catch (error) { + if (error instanceof PhotosPermissionDeniedError) { + showPhotosSettingsAlert(); + return; + } + Alert.alert( + "Save failed", + error instanceof Error ? error.message : "Unable to save this video.", + ); + } finally { + setSavingId(null); + } + }, + [auth, savingId], + ); + + const showPasswordActions = useCallback( + (cap: MobileCapSummary) => { + if (auth.status !== "signedIn") return; + showCapPasswordActions({ + cap, + client: auth.client, + onUpdated: async (updated) => { + setSettingsCap((current) => + current?.id === updated.id ? updated : current, + ); + await Promise.all([auth.refresh(), load()]); + }, + }); + }, + [auth, load], + ); + + const showTitleActions = useCallback( + (cap: MobileCapSummary) => { + if (auth.status !== "signedIn") return; + showCapTitleActions({ + cap, + client: auth.client, + onUpdated: async (updated) => { + setSettingsCap((current) => + current?.id === updated.id ? updated : current, + ); + await Promise.all([auth.refresh(), load()]); + }, + }); + }, + [auth, load], + ); + + const showCapSettings = useCallback((cap: MobileCapSummary) => { + setSettingsCap(cap); + }, []); + + const viewAnalytics = useCallback((cap: MobileCapSummary) => { + const url = new URL("/dashboard/analytics", apiBaseUrl); + url.searchParams.set("capId", cap.id); + void WebBrowser.openBrowserAsync(url.toString()); + }, []); + + const createFolder = useCallback( + async (name: string, color: MobileFolder["color"]) => { + if (auth.status !== "signedIn" || creatingFolder) return; + const trimmedName = name.trim(); + if (!trimmedName) { + Alert.alert("Folder name required", "Enter a folder name to continue."); + return; + } + + setCreatingFolder(true); + setCreatingFolderName(trimmedName); + try { + await auth.client.createFolder({ name: trimmedName, color }); + setFolder(null); + await Promise.all([auth.refresh(), load()]); + } catch (error) { + Alert.alert( + "Folder creation failed", + error instanceof Error + ? error.message + : "Unable to create this folder.", + ); + } finally { + setCreatingFolder(false); + setCreatingFolderName(null); + } + }, + [auth, creatingFolder, load], + ); + + const showFolderColorSheet = useCallback( + (name: string) => { + if (Platform.OS !== "ios") { + void createFolder(name, "normal"); + return; + } + + const cancelButtonIndex = folderColorOptions.length; + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex, + message: name, + options: [ + ...folderColorOptions.map((option) => option.label), + "Cancel", + ], + title: "Folder color", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + const option = folderColorOptions[index]; + if (option) void createFolder(name, option.color); + }, + ); + }, + [createFolder], + ); + + const showNewFolderPrompt = useCallback(() => { + if (auth.status !== "signedIn" || creatingFolder) return; + + if (Platform.OS === "ios") { + Alert.prompt( + "New Folder", + "Name this folder.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Next", + onPress: (value?: string) => { + const name = value?.trim() ?? ""; + if (!name) { + Alert.alert( + "Folder name required", + "Enter a folder name to continue.", + ); + return; + } + showFolderColorSheet(name); + }, + }, + ], + "plain-text", + ); + return; + } + + Alert.alert("New Folder", "Create a folder named Untitled?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Create", + onPress: () => { + void createFolder("Untitled", "normal"); + }, + }, + ]); + }, [auth.status, createFolder, creatingFolder, showFolderColorSheet]); + + const showSharingActions = useCallback( + (cap: MobileCapSummary) => { + if (updatingSharingId !== null) return; + const visibilityAction = cap.public ? "Make private" : "Make public"; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 3, + message: cap.shareUrl, + options: [visibilityAction, "Copy link", "Share link", "Cancel"], + title: cap.public ? "Shared" : "Not shared", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) void updateCapVisibility(cap, !cap.public); + if (index === 1) copyCapLink(cap); + if (index === 2) shareCapLink(cap); + }, + ); + return; + } + + Alert.alert(cap.public ? "Shared" : "Not shared", cap.shareUrl, [ + { + text: visibilityAction, + onPress: () => void updateCapVisibility(cap, !cap.public), + }, + { text: "Copy link", onPress: () => copyCapLink(cap) }, + { text: "Share link", onPress: () => shareCapLink(cap) }, + { text: "Cancel", style: "cancel" }, + ]); + }, + [copyCapLink, shareCapLink, updateCapVisibility, updatingSharingId], + ); + + const items = useMemo(() => { + if (!result) return []; + const nextItems: ListItem[] = []; + if (result.folders.length > 0) { + nextItems.push({ type: "section", id: "folders", title: "Folders" }); + nextItems.push( + ...result.folders.map((item) => ({ + type: "folder" as const, + folder: item, + })), + ); + } + if (result.caps.length > 0) { + nextItems.push({ type: "section", id: "videos", title: "Videos" }); + nextItems.push( + ...result.caps.map((item) => ({ type: "cap" as const, cap: item })), + ); + } + return nextItems; + }, [result]); + + const userName = auth.bootstrap?.user.name?.split(" ")[0]; + const folderCreationHint = creatingFolder + ? "Folder creation is in progress" + : "Creates a folder for organizing Caps"; + const folderCreationStatus = creatingFolder + ? `Creating folder ${creatingFolderName ?? ""}`.trim() + : null; + const folderCreationAccessibilityLabel = "New Folder"; + const folderCreationAccessibilityValue = folderCreationStatus + ? { text: folderCreationStatus } + : undefined; + const dashboardActionHint = creatingFolder + ? "Folder creation is in progress" + : null; + const savingCap = + savingId !== null + ? settingsCap?.id === savingId + ? settingsCap + : (result?.caps.find((cap) => cap.id === savingId) ?? null) + : null; + const updatingSharingCap = + updatingSharingId !== null + ? settingsCap?.id === updatingSharingId + ? settingsCap + : (result?.caps.find((cap) => cap.id === updatingSharingId) ?? null) + : null; + const isLibraryActionInProgress = + savingId !== null || updatingSharingId !== null; + const saveDisabledHint = + savingId !== null + ? "Save is in progress" + : "Current Cap action is in progress"; + const visibilityDisabledHint = + updatingSharingId !== null + ? "Sharing update is in progress" + : "Current Cap action is in progress"; + const saveDisabledAccessibilityValue = savingCap + ? `Saving video for ${savingCap.title}` + : undefined; + const visibilityDisabledAccessibilityValue = updatingSharingCap + ? `Updating sharing for ${updatingSharingCap.title}` + : undefined; + + if (auth.status === "loading") { + return ; + } + + if (auth.status === "signedOut") { + return ( + + + + ); + } + + return ( + + {auth.bootstrap ? ( + + { + setFolder(null); + await auth.setActiveOrganization(organizationId); + await load(); + }} + /> + + ) : null} + + + router.push("/upload")} + disabled={creatingFolder} + size="sm" + style={styles.actionButton} + symbol="square.and.arrow.up" + variant="dark" + /> + + {folder ? ( + setFolder(null)} + style={styles.folderCrumb} + > + My Caps + + + + + + {folder.name} + + + ) : null} + {loadError ? ( + + + + + + Unable to load Caps + {loadError} + + + + ) : null} + {loadError && !result ? null : ( + + item.type === "section" + ? `section-${item.id}` + : item.type === "folder" + ? `folder-${item.folder.id}` + : `cap-${item.cap.id}` + } + refreshControl={ + + } + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContent} + getItemType={(item) => item.type} + ListEmptyComponent={ + + + + + + + + + + Hey{userName ? ` ${userName}` : ""}! Import your first Cap + + + Bring videos into Cap and share them instantly. + + + router.push("/upload")} + disabled={creatingFolder} + style={styles.emptyButton} + symbol="square.and.arrow.up" + variant="dark" + /> + + + } + renderItem={({ item }) => + item.type === "section" ? ( + + {item.title} + + ) : item.type === "folder" ? ( + setFolder(item.folder)} + style={({ pressed }) => [ + styles.folderRow, + pressed ? styles.folderRowPressed : null, + ]} + > + + + + + + {item.folder.name} + + + {item.folder.videoCount}{" "} + {item.folder.videoCount === 1 ? "video" : "videos"} + + + + + ) : ( + viewAnalytics(item.cap)} + onCopyPress={() => copyCapLink(item.cap)} + onPress={() => router.push(`/caps/${item.cap.id}`)} + onSharePress={() => shareCapLink(item.cap)} + onVisibilityPress={() => showSharingActions(item.cap)} + onMenuPress={() => showCapSettings(item.cap)} + visibilityBusy={updatingSharingId === item.cap.id} + visibilityDisabled={updatingSharingId !== null} + visibilityDisabledHint={ + updatingSharingId === item.cap.id + ? "Sharing update is in progress" + : "Another sharing update is in progress" + } + visibilityAccessibilityValue={ + updatingSharingId === item.cap.id + ? `Updating sharing for ${item.cap.title}` + : undefined + } + /> + ) + } + /> + )} + setSettingsCap(null)} + onCopyLink={copyCapLink} + onDelete={confirmDeleteCap} + onPassword={showPasswordActions} + onRename={showTitleActions} + onSaveVideo={(cap) => { + void saveCapVideo(cap); + }} + onShareLink={shareCapLink} + onViewAnalytics={viewAnalytics} + onVisibilityChange={(cap, isPublic) => { + void updateCapVisibility(cap, isPublic); + }} + saveDisabled={isLibraryActionInProgress} + saveDisabledHint={saveDisabledHint} + saveDisabledValue={savingId !== null ? undefined : "Unavailable"} + saveDisabledAccessibilityValue={saveDisabledAccessibilityValue} + visibilityDisabled={isLibraryActionInProgress} + visibilityDisabledHint={visibilityDisabledHint} + visibilityDisabledAccessibilityValue={ + visibilityDisabledAccessibilityValue + } + /> + + ); +} + +const styles = StyleSheet.create({ + topBar: { + marginBottom: 12, + }, + actions: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginBottom: 40, + }, + actionButton: { + flexGrow: 1, + flexBasis: 104, + paddingHorizontal: 12, + }, + listContent: { + paddingBottom: 22, + }, + folderCrumb: { + minHeight: 40, + flexDirection: "row", + alignItems: "center", + gap: 7, + marginBottom: 14, + }, + folderCrumbText: { + fontFamily: fonts.medium, + color: colors.gray9, + fontSize: 20, + lineHeight: 26, + }, + folderCrumbIcon: { + width: 24, + height: 24, + alignItems: "center", + justifyContent: "center", + }, + folderCurrent: { + flex: 1, + fontFamily: fonts.medium, + color: colors.gray12, + fontSize: 20, + lineHeight: 26, + }, + folderRow: { + minHeight: 82, + flexDirection: "row", + alignItems: "center", + borderRadius: radius.sm, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: 16, + paddingVertical: 16, + gap: 12, + marginBottom: 12, + backgroundColor: colors.gray3, + borderColor: colors.gray5, + ...squircle, + }, + folderRowPressed: { + backgroundColor: colors.gray4, + borderColor: colors.gray6, + }, + sectionHeader: { + paddingTop: 8, + paddingBottom: 24, + }, + sectionTitle: { + fontFamily: fonts.medium, + fontSize: 24, + lineHeight: 30, + color: colors.gray12, + }, + folderIcon: { + width: 50, + height: 50, + alignItems: "center", + justifyContent: "center", + }, + folderText: { + flex: 1, + minWidth: 0, + }, + folderName: { + fontFamily: fonts.regular, + fontSize: 15, + lineHeight: 22, + color: colors.gray12, + }, + folderMeta: { + fontFamily: fonts.regular, + fontSize: 13, + lineHeight: 18, + color: colors.gray10, + }, + errorCard: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: colors.gray1, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + padding: 14, + marginBottom: 14, + ...squircle, + }, + errorIcon: { + width: 36, + height: 36, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + ...squircle, + }, + errorCopy: { + flex: 1, + minWidth: 0, + }, + errorTitle: { + fontFamily: fonts.medium, + fontSize: 15, + lineHeight: 20, + color: colors.gray12, + }, + errorText: { + fontFamily: fonts.regular, + fontSize: 13, + lineHeight: 18, + color: colors.gray10, + marginTop: 2, + }, + errorButton: { + paddingHorizontal: 14, + }, + emptyState: { + alignItems: "center", + paddingTop: 42, + gap: 12, + paddingHorizontal: 8, + }, + emptyArt: { + width: 180, + height: 112, + alignItems: "center", + justifyContent: "center", + marginBottom: 10, + }, + emptyArtCard: { + position: "absolute", + width: 152, + height: 86, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + backgroundColor: colors.gray1, + transform: [{ rotate: "-4deg" }], + ...squircle, + }, + emptyArtCardBack: { + backgroundColor: colors.gray3, + borderColor: colors.gray4, + transform: [{ translateX: 12 }, { translateY: 7 }, { rotate: "5deg" }], + }, + emptyLogo: { + width: 72, + height: 72, + borderRadius: radius.lg, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.white, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + ...squircle, + }, + emptyTitle: { + fontFamily: fonts.medium, + fontSize: 20, + color: colors.gray12, + textAlign: "center", + }, + emptyText: { + fontFamily: fonts.regular, + fontSize: 15, + lineHeight: 22, + color: colors.gray10, + textAlign: "center", + }, + emptyActions: { + width: "100%", + flexDirection: "row", + gap: 10, + marginTop: 4, + }, + emptyButton: { + flex: 1, + }, +}); diff --git a/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx new file mode 100644 index 0000000000..b0880df9d6 --- /dev/null +++ b/apps/mobile/src/screens/dashboard-upload-visibility.test.tsx @@ -0,0 +1,2377 @@ +import React, { type ReactElement, type ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CapsScreen from "../../app/(tabs)"; +import UploadScreen from "../../app/(tabs)/upload"; + +type AuthStub = { + status: "signedIn"; + bootstrap: { + activeOrganizationId: string; + user: { + email: string; + name: string | null; + }; + }; + client: { + createFolder: (input: { color?: string; name: string }) => Promise<{ + color: string; + id: string; + name: string; + parentId: null; + videoCount: number; + }>; + getCap: (id: string) => Promise<{ + cap: { + upload: null; + }; + }>; + listCaps: () => Promise<{ + caps: unknown[]; + folders: unknown[]; + pagination: { + hasNextPage: boolean; + page: number; + totalPages: number; + }; + rootFolders: unknown[]; + }>; + updateCapSharing: ( + id: string, + input: { public: boolean }, + ) => Promise; + }; + refresh: () => Promise; +}; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +const createAuth = (): AuthStub => ({ + status: "signedIn", + bootstrap: { + activeOrganizationId: "org_123", + user: { + email: "richie@cap.so", + name: "Richie", + }, + }, + client: { + createFolder: vi.fn((input: { color?: string; name: string }) => + Promise.resolve({ + id: "folder_123", + name: input.name, + color: input.color ?? "normal", + parentId: null, + videoCount: 0, + }), + ), + getCap: () => + Promise.resolve({ + cap: { + upload: null, + }, + }), + listCaps: () => + Promise.resolve({ + caps: [], + folders: [], + pagination: { + hasNextPage: false, + page: 1, + totalPages: 1, + }, + rootFolders: [], + }), + updateCapSharing: vi.fn((id: string, input: { public: boolean }) => + Promise.resolve({ + id, + public: input.public, + }), + ), + }, + refresh: () => Promise.resolve(), +}); + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +}; + +const authState = vi.hoisted((): { value: AuthStub | null } => ({ + value: null, +})); + +const uploadQueueState = vi.hoisted( + (): { + value: { + items: Array<{ + capId: string | null; + contentType: string; + createdAt: string; + error: string | null; + fileName: string; + folderId: string | null; + id: string; + localUri: string; + organizationId: string | null; + progress: number; + processingMessage?: string | null; + rawFileKey: string | null; + size: number; + durationSeconds?: number; + status: "complete" | "failed" | "processing" | "queued" | "uploading"; + updatedAt: string; + }>; + }; + } => ({ + value: { + items: [], + }, + }), +); + +const uploadQueueActionsState = vi.hoisted((): { value: unknown[] } => ({ + value: [], +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderTree = async (node: ReactElement): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return (renderer as ReactTestRenderer | null)?.toJSON() ?? null; +}; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const hasProp = (node: JsonNode, prop: string, value: unknown): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) + return node.some((item) => hasProp(item, prop, value)); + if (node.props[prop] === value) return true; + return node.children?.some((child) => hasProp(child, prop, value)) ?? false; +}; + +const propMatches = (actual: unknown, expected: unknown): boolean => { + if ( + expected && + typeof expected === "object" && + !Array.isArray(expected) && + actual && + typeof actual === "object" && + !Array.isArray(actual) + ) { + return Object.entries(expected).every( + ([key, value]) => (actual as Record)[key] === value, + ); + } + + return actual === expected; +}; + +const hasProps = ( + node: JsonNode, + expected: Record, +): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) return node.some((item) => hasProps(item, expected)); + if ( + Object.entries(expected).every(([key, value]) => + propMatches(node.props[key], value), + ) + ) { + return true; + } + return node.children?.some((child) => hasProps(child, expected)) ?? false; +}; + +const hasStyle = ( + node: JsonNode, + expected: Record, +): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) return node.some((item) => hasStyle(item, expected)); + const style = + typeof node.props.style === "function" + ? node.props.style({ pressed: false }) + : node.props.style; + const resolved = Array.isArray(style) + ? Object.assign({}, ...style.filter(Boolean)) + : style; + if ( + resolved && + Object.entries(expected).every(([key, value]) => resolved[key] === value) + ) { + return true; + } + return node.children?.some((child) => hasStyle(child, expected)) ?? false; +}; + +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = typeof style === "function" ? style({ pressed }) : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActionSheetIOS: { + showActionSheetWithOptions: vi.fn(), + }, + ActivityIndicator: createHost("ActivityIndicator"), + Alert: { + alert: vi.fn(), + prompt: vi.fn(), + }, + AppState: { + addEventListener: vi.fn(() => ({ + remove: vi.fn(), + })), + }, + Linking: { + openSettings: vi.fn(), + }, + Modal: createHost("Modal"), + Platform: { + OS: "ios", + select: (values: { default?: T; ios?: T }) => + values.ios ?? values.default, + }, + Pressable: createHost("Pressable"), + RefreshControl: createHost("RefreshControl"), + Share: { + share: vi.fn(), + }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: "absolute", + right: 0, + top: 0, + }, + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Switch: createHost("Switch"), + Text: createHost("Text"), + TextInput: createHost("TextInput"), + View: createHost("View"), + }; +}); + +vi.mock("@shopify/flash-list", async () => { + const React = await import("react"); + return { + FlashList: ({ + data, + ListEmptyComponent, + renderItem, + }: { + data?: unknown[]; + ListEmptyComponent?: ReactNode; + renderItem?: (info: { index: number; item: unknown }) => ReactNode; + }) => + React.createElement( + "FlashList", + null, + data && data.length > 0 + ? data.map((item, index) => + React.createElement( + React.Fragment, + { key: index }, + renderItem?.({ item, index }), + ), + ) + : ListEmptyComponent, + ), + }; +}); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: vi.fn(), +})); + +vi.mock("expo-router", () => ({ + router: { + push: vi.fn(), + }, +})); + +vi.mock("expo-web-browser", () => ({ + openBrowserAsync: vi.fn(), +})); + +vi.mock("@/auth/AuthContext", () => ({ + apiBaseUrl: "https://cap.so", + useAuth: () => authState.value, +})); + +vi.mock("@/auth/SignInPanel", async () => { + const React = await import("react"); + return { + SignInPanel: () => React.createElement("SignInPanel"), + }; +}); + +vi.mock("@/components/ActionButton", async () => { + const React = await import("react"); + return { + ActionButton: ({ + children, + label, + onPress, + ...props + }: { + children?: ReactNode; + label: string; + onPress?: () => void; + [key: string]: unknown; + }) => + React.createElement( + "ActionButton", + { accessibilityLabel: label, onPress, ...props }, + children ?? label, + ), + }; +}); + +vi.mock("@/components/Screen", async () => { + const React = await import("react"); + + return { + Screen: ({ + children, + loading, + subtitle, + title, + }: { + children?: ReactNode; + loading?: boolean; + subtitle?: string | null; + title?: string; + }) => + React.createElement( + "Screen", + null, + title ? React.createElement("Text", null, title) : null, + subtitle ? React.createElement("Text", null, subtitle) : null, + loading ? React.createElement("Text", null, "Loading") : children, + ), + }; +}); + +vi.mock("@/components/GlassSurface", async () => { + const React = await import("react"); + return { + GlassSurface: ({ children }: { children?: ReactNode }) => + React.createElement("GlassSurface", null, children), + }; +}); + +vi.mock("@/components/CapCard", async () => { + const React = await import("react"); + return { + CapCard: (props: HostProps) => React.createElement("CapCard", props), + }; +}); + +vi.mock("@/components/OrgSwitcher", async () => { + const React = await import("react"); + return { + OrgSwitcher: () => React.createElement("OrgSwitcher"), + }; +}); + +vi.mock("expo-symbols", () => ({ + SymbolView: () => null, +})); + +vi.mock("react-native-svg", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + default: createHost("Svg"), + Path: createHost("Path"), + Rect: createHost("Rect"), + }; +}); + +vi.mock("@/theme", () => ({ + colors: { + appBackground: "#f9f9f9", + black: "#000000", + blackAlpha40: "rgba(18, 22, 31, 0.4)", + blue11: "#0d74ce", + blue3: "#edf6ff", + blue6: "#acd8fc", + blue9: "#0090ff", + buttonBlue: "#2563eb", + buttonBlueBorder: "#1e40af", + glass: "rgba(252, 252, 252, 0.72)", + gray1: "#fcfcfc", + gray10: "#838383", + gray12: "#202020", + gray2: "#f9f9f9", + gray3: "#f0f0f0", + gray4: "#e8e8e8", + gray5: "#e0e0e0", + gray6: "#d9d9d9", + gray9: "#8d8d8d", + red1: "#fffcfc", + red3: "#feebec", + red6: "#fdbdbe", + red9: "#e5484d", + white: "#ffffff", + yellow3: "#fffab8", + yellow5: "#ffe770", + yellow9: "#f5d90a", + }, + fonts: { + bold: "NeueMontreal-Bold", + medium: "NeueMontreal-Medium", + regular: "NeueMontreal-Regular", + }, + radius: { + full: 999, + lg: 16, + md: 12, + sm: 8, + xl: 20, + xs: 6, + }, + shadows: { + card: {}, + popover: {}, + }, + squircle: { + borderCurve: "continuous", + }, +})); + +vi.mock("expo-document-picker", () => ({ + getDocumentAsync: vi.fn(), +})); + +vi.mock("expo-file-system/legacy", () => ({ + documentDirectory: "file:///tmp/", + downloadAsync: vi.fn(), +})); + +vi.mock("expo-image-picker", () => ({ + launchImageLibraryAsync: vi.fn(), + requestMediaLibraryPermissionsAsync: vi.fn(), +})); + +vi.mock("expo-media-library", () => ({ + requestPermissionsAsync: vi.fn(), + saveToLibraryAsync: vi.fn(), +})); + +vi.mock("@/uploads/runMobileUpload", () => ({ + runMobileUpload: vi.fn(), +})); + +vi.mock("@/uploads/uploadQueue", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + emptyUploadQueue: uploadQueueState.value, + uploadProgressPercent: (progress: number) => Math.round(progress * 100), + uploadQueueReducer: (state: { items: unknown[] }, action: unknown) => { + uploadQueueActionsState.value.push(action); + return state; + }, + uploadQueueStatusText: (item: { + processingMessage?: string | null; + progress: number; + status: string; + }) => { + if (item.status === "complete") return "Ready to view"; + if (item.status === "failed") return "Upload failed"; + if (item.status === "processing") { + return item.processingMessage ?? "Finishing up"; + } + if (item.status === "uploading") { + return `Uploading ${Math.round(item.progress * 100)}%`; + } + return "Queued"; + }, + }; +}); + +describe("upload and dashboard visibility", () => { + beforeEach(() => { + authState.value = createAuth(); + uploadQueueState.value.items = []; + uploadQueueActionsState.value = []; + }); + + it("shows native upload entry points", async () => { + const tree = await renderTree(React.createElement(UploadScreen)); + + expect(getTextNodes(tree)).toContain("Import"); + expect(getTextNodes(tree)).toContain("Upload File"); + expect(getTextNodes(tree)).toContain("Browse Files"); + expect(getTextNodes(tree)).toContain("Photos"); + expect(getTextNodes(tree)).toContain("Import from Loom"); + expect(getTextNodes(tree)).toContain("MP4, MOV, AVI, MKV, WebM, or M4V"); + expect(hasStyle(tree, { height: 128, backgroundColor: "#f0f0f0" })).toBe( + true, + ); + expect( + hasProps(tree, { + accessibilityHint: "Opens upload source options", + accessibilityLabel: "Choose upload source", + accessibilityState: { busy: false, disabled: false }, + accessibilityValue: { + text: "MP4, MOV, AVI, MKV, WebM, or M4V", + }, + }), + ).toBe(true); + expect( + hasProps(tree, { + accessibilityHint: "Opens Loom import in a browser sheet", + accessibilityLabel: "Open Loom import", + }), + ).toBe(true); + expect( + hasProps(tree, { + accessibilityHint: "Opens the native file picker", + accessibilityLabel: "Browse Files", + }), + ).toBe(true); + expect( + hasProps(tree, { + accessibilityHint: "Opens your photo library", + accessibilityLabel: "Photos", + }), + ).toBe(true); + }); + + it("opens the native iOS upload source sheet", async () => { + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Choose upload source", + }); + if (!uploadSource) throw new Error("Upload source button was not rendered"); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + uploadSource.props.onPress(); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 3, + options: ["Browse Files", "Photos", "Import from Loom", "Cancel"], + tintColor: "#0d74ce", + title: "Upload File", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + + const [, callback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!callback) throw new Error("Upload source callback was not set"); + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + + await act(async () => { + callback(2); + }); + + expect(openBrowserAsync).toHaveBeenCalledWith( + "https://cap.so/dashboard/import/loom", + ); + }); + + it("opens Loom import in the native browser sheet", async () => { + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if (!loomAction) throw new Error("Loom upload action was not rendered"); + if (!loomImport) throw new Error("Loom import card was not rendered"); + + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + + expect(loomAction.props.accessibilityHint).toBe( + "Opens Loom import in a browser sheet", + ); + + await act(async () => { + loomAction.props.onPress(); + }); + + expect(openBrowserAsync).toHaveBeenCalledWith( + "https://cap.so/dashboard/import/loom", + ); + openBrowserAsync.mockClear(); + + await act(async () => { + loomImport.props.onPress(); + }); + + expect(openBrowserAsync).toHaveBeenCalledWith( + "https://cap.so/dashboard/import/loom", + ); + }); + + it("shows Loom import failures on the Loom card", async () => { + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + openBrowserAsync.mockRejectedValueOnce(new Error("Loom unavailable")); + + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if (!loomImport) throw new Error("Loom import card was not rendered"); + + await act(async () => { + loomImport.props.onPress(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getTextNodes(renderer.toJSON())).toContain( + "Loom import unavailable", + ); + expect(getTextNodes(renderer.toJSON())).toContain("Loom unavailable"); + const [failedLoomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Loom import unavailable", + }); + if (!failedLoomImport) throw new Error("Loom error card was not rendered"); + expect(failedLoomImport.props.accessibilityHint).toBe( + "Retries Loom import", + ); + expect(failedLoomImport.props.accessibilityValue).toEqual({ + text: "Loom unavailable", + }); + expect(failedLoomImport.props.accessibilityState).toEqual({ + busy: false, + disabled: false, + }); + const [retryLoomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Retry Loom", + }); + if (!retryLoomAction) throw new Error("Retry Loom action was not rendered"); + expect(retryLoomAction.props.accessibilityHint).toBe("Loom unavailable"); + expect(retryLoomAction.props.accessibilityValue).toEqual({ + text: "Loom unavailable", + }); + expect(retryLoomAction.props.disabled).toBe(false); + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Choose upload source", + }); + if (!uploadSource) throw new Error("Upload source card was not rendered"); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "MP4, MOV, AVI, MKV, WebM, or M4V", + }); + expect(hasStyle(renderer.toJSON(), { color: "#e5484d" })).toBe(true); + expect( + hasProps(renderer.toJSON(), { + accessibilityLiveRegion: "polite", + accessibilityRole: "alert", + }), + ).toBe(true); + }); + + it("locks stale Loom import actions while the browser sheet is opening", async () => { + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + let resolveBrowser: + | (( + value: Awaited>, + ) => void) + | null = null; + openBrowserAsync.mockClear(); + openBrowserAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBrowser = resolve; + }), + ); + + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if (!loomAction) throw new Error("Loom upload action was not rendered"); + if (!loomImport) throw new Error("Loom import card was not rendered"); + + await act(async () => { + void loomAction.props.onPress(); + await Promise.resolve(); + }); + + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Choose upload source", + }); + const [loadingLoomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Opening Loom", + }); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + const [loadingLoomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + if (!uploadSource) throw new Error("Upload source button was not rendered"); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + if (!loadingLoomAction) + throw new Error("Loom upload action was not rendered"); + if (!loadingLoomImport) + throw new Error("Loom import card was not rendered"); + + const loadingText = getTextNodes(renderer.toJSON()); + expect(loadingText.filter((item) => item === "Opening Loom")).toHaveLength( + 1, + ); + expect(getTextNodes(renderer.toJSON())).not.toContain("Opening Loom..."); + expect( + loadingText.filter( + (item) => item === "Continue in the browser sheet to import from Loom.", + ), + ).toHaveLength(1); + expect(uploadSource.props.accessibilityHint).toBe("Loom import is opening"); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "Opening Loom import", + }); + expect(uploadSource.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(browseButton.props.disabled).toBe(true); + expect(browseButton.props.accessibilityHint).toBe("Loom import is opening"); + expect(browseButton.props.accessibilityValue).toEqual({ + text: "Opening Loom import", + }); + expect(loadingLoomAction.props.accessibilityHint).toBe( + "Loom import is opening", + ); + expect(loadingLoomAction.props.accessibilityValue).toEqual({ + text: "Opening Loom import", + }); + expect(loadingLoomAction.props.loading).toBe(true); + expect(loadingLoomAction.props.disabled).toBe(false); + expect(loadingLoomImport.props.accessibilityHint).toBe( + "Loom import is opening", + ); + expect(loadingLoomImport.props.accessibilityValue).toEqual({ + text: "Opening Loom import", + }); + expect(loadingLoomImport.props.disabled).toBe(true); + expect(openBrowserAsync).toHaveBeenCalledTimes(1); + + openBrowserAsync.mockClear(); + + await act(async () => { + loomAction.props.onPress(); + loomImport.props.onPress(); + uploadSource.props.onPress(); + await Promise.resolve(); + }); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + + await act(async () => { + resolveBrowser?.({ + type: "dismiss", + } as Awaited>); + await Promise.resolve(); + }); + }); + + it("locks upload sources while the file picker is opening", async () => { + const DocumentPicker = await import("expo-document-picker"); + let resolvePicker: + | (( + value: Awaited>, + ) => void) + | null = null; + vi.mocked(DocumentPicker.getDocumentAsync).mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePicker = resolve; + }), + ); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + void browseButton.props.onPress(); + await Promise.resolve(); + }); + + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Opening Files", + }); + const [loadingBrowseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + const [photosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Photos", + }); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if ( + !uploadSource || + !loadingBrowseButton || + !photosButton || + !loomAction || + !loomImport + ) { + throw new Error("Upload source controls were not rendered"); + } + + expect(getTextNodes(renderer.toJSON())).toContain("Opening Files"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Opening Files..."); + expect(getTextNodes(renderer.toJSON())).toContain( + "Choose a video from Files.", + ); + expect(uploadSource.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "Opening native file picker", + }); + expect(uploadSource.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(uploadSource.props.disabled).toBe(true); + expect(resolveStyle(uploadSource.props.style)).toMatchObject({ + opacity: 0.58, + }); + expect(loadingBrowseButton.props.loading).toBe(true); + expect(loadingBrowseButton.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(loadingBrowseButton.props.accessibilityValue).toEqual({ + text: "Opening native file picker", + }); + expect(photosButton.props.accessibilityHint).toBe( + "Another upload source is opening", + ); + expect(photosButton.props.accessibilityValue).toEqual({ + text: "Opening native file picker", + }); + expect(photosButton.props.disabled).toBe(true); + expect(loomAction.props.accessibilityHint).toBe( + "Another upload source is opening", + ); + expect(loomAction.props.accessibilityValue).toEqual({ + text: "Opening native file picker", + }); + expect(loomAction.props.disabled).toBe(true); + expect(loomImport.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(loomImport.props.accessibilityValue).toEqual({ + text: "Opening native file picker", + }); + expect(loomImport.props.disabled).toBe(true); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + const ImagePicker = await import("expo-image-picker"); + const requestMediaLibraryPermissionsAsync = vi.mocked( + ImagePicker.requestMediaLibraryPermissionsAsync, + ); + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + showActionSheetWithOptions.mockClear(); + requestMediaLibraryPermissionsAsync.mockClear(); + openBrowserAsync.mockClear(); + + await act(async () => { + uploadSource.props.onPress(); + photosButton.props.onPress(); + loomAction.props.onPress(); + loomImport.props.onPress(); + }); + + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + expect(requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled(); + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledTimes(1); + + await act(async () => { + resolvePicker?.({ + assets: null, + canceled: true, + } as Awaited>); + await Promise.resolve(); + }); + }); + + it("shows the active Photos source as loading while the photo picker is opening", async () => { + const ImagePicker = await import("expo-image-picker"); + const requestMediaLibraryPermissionsAsync = vi.mocked( + ImagePicker.requestMediaLibraryPermissionsAsync, + ); + let resolvePermission: + | (( + value: Awaited< + ReturnType + >, + ) => void) + | null = null; + requestMediaLibraryPermissionsAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePermission = resolve; + }), + ); + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [photosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Photos", + }); + if (!photosButton) throw new Error("Photos button was not rendered"); + + await act(async () => { + void photosButton.props.onPress(); + await Promise.resolve(); + }); + + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Opening Photos", + }); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + const [loadingPhotosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Photos", + }); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if ( + !uploadSource || + !browseButton || + !loadingPhotosButton || + !loomAction || + !loomImport + ) { + throw new Error("Upload source controls were not rendered"); + } + + expect(getTextNodes(renderer.toJSON())).toContain("Opening Photos"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Opening Photos..."); + expect(getTextNodes(renderer.toJSON())).toContain( + "Choose a video from Photos.", + ); + expect(uploadSource.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "Opening native photo picker", + }); + expect(uploadSource.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(uploadSource.props.disabled).toBe(true); + expect(resolveStyle(uploadSource.props.style)).toMatchObject({ + opacity: 0.58, + }); + expect(browseButton.props.accessibilityHint).toBe( + "Another upload source is opening", + ); + expect(browseButton.props.accessibilityValue).toEqual({ + text: "Opening native photo picker", + }); + expect(browseButton.props.disabled).toBe(true); + expect(loadingPhotosButton.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(loadingPhotosButton.props.accessibilityValue).toEqual({ + text: "Opening native photo picker", + }); + expect(loadingPhotosButton.props.loading).toBe(true); + expect(loadingPhotosButton.props.disabled).toBe(false); + expect(loomAction.props.accessibilityHint).toBe( + "Another upload source is opening", + ); + expect(loomAction.props.accessibilityValue).toEqual({ + text: "Opening native photo picker", + }); + expect(loomAction.props.disabled).toBe(true); + expect(loomImport.props.accessibilityHint).toBe( + "Upload source picker is opening", + ); + expect(loomImport.props.accessibilityValue).toEqual({ + text: "Opening native photo picker", + }); + expect(loomImport.props.disabled).toBe(true); + + await act(async () => { + resolvePermission?.({ + granted: false, + } as Awaited< + ReturnType + >); + await Promise.resolve(); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 1, + message: "Allow Cap to read videos from Photos before uploading.", + options: ["Open Settings", "Cancel"], + title: "Photos access needed", + }), + expect.any(Function), + ); + expect(getTextNodes(renderer.toJSON())).toContain( + "Upload source unavailable", + ); + expect(getTextNodes(renderer.toJSON())).toContain( + "Allow Cap to read videos from Photos before uploading.", + ); + const [failedUploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload source unavailable", + }); + const [retryPhotosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Retry Photos", + }); + if (!failedUploadSource) + throw new Error("Upload source error state was not rendered"); + if (!retryPhotosButton) + throw new Error("Retry Photos button was not rendered"); + expect(failedUploadSource.props.accessibilityValue).toEqual({ + text: "Allow Cap to read videos from Photos before uploading.", + }); + expect(retryPhotosButton.props.accessibilityHint).toBe( + "Allow Cap to read videos from Photos before uploading.", + ); + expect(retryPhotosButton.props.accessibilityValue).toEqual({ + text: "Allow Cap to read videos from Photos before uploading.", + }); + expect(retryPhotosButton.props.disabled).toBe(false); + }); + + it("deduplicates stale upload source actions while the file picker is opening", async () => { + const DocumentPicker = await import("expo-document-picker"); + const getDocumentAsync = vi.mocked(DocumentPicker.getDocumentAsync); + let resolvePicker: + | (( + value: Awaited>, + ) => void) + | null = null; + getDocumentAsync.mockClear(); + getDocumentAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePicker = resolve; + }), + ); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Choose upload source", + }); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + const [photosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Photos", + }); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if ( + !uploadSource || + !browseButton || + !photosButton || + !loomAction || + !loomImport + ) { + throw new Error("Upload source controls were not rendered"); + } + + await act(async () => { + void browseButton.props.onPress(); + await Promise.resolve(); + }); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + const ImagePicker = await import("expo-image-picker"); + const requestMediaLibraryPermissionsAsync = vi.mocked( + ImagePicker.requestMediaLibraryPermissionsAsync, + ); + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + showActionSheetWithOptions.mockClear(); + requestMediaLibraryPermissionsAsync.mockClear(); + openBrowserAsync.mockClear(); + + await act(async () => { + uploadSource.props.onPress(); + browseButton.props.onPress(); + photosButton.props.onPress(); + loomAction.props.onPress(); + loomImport.props.onPress(); + await Promise.resolve(); + }); + + expect(getDocumentAsync).toHaveBeenCalledTimes(1); + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + expect(requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled(); + expect(openBrowserAsync).not.toHaveBeenCalled(); + + await act(async () => { + resolvePicker?.({ + assets: null, + canceled: true, + } as Awaited>); + await Promise.resolve(); + }); + }); + + it("locks Loom import while a device upload is active", async () => { + const DocumentPicker = await import("expo-document-picker"); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + const uploadStartedAt = 1_763_440_800_000; + const dateNow = vi.spyOn(Date, "now").mockReturnValue(uploadStartedAt); + let resolveUpload: + | ((value: Awaited>) => void) + | null = null; + uploadQueueState.value.items = [ + { + capId: null, + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: null, + fileName: "launch-review.mp4", + folderId: null, + id: `${uploadStartedAt}-launch-review.mp4`, + localUri: "file:///tmp/launch-review.mp4", + organizationId: "org_123", + progress: 0, + processingMessage: null, + rawFileKey: null, + size: 12_400_000, + status: "queued", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + mimeType: "video/mp4", + name: "launch-review.mp4", + size: 12_400_000, + uri: "file:///tmp/launch-review.mp4", + }, + ], + canceled: false, + } as Awaited>); + vi.mocked(runMobileUpload).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + void browseButton.props.onPress(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const [uploadSource] = renderer.root.findAllByProps({ + accessibilityLabel: "Choose upload source", + }); + const [loomImport] = renderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + const [activeBrowseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + const [photosButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Photos", + }); + const [loomAction] = renderer.root.findAllByProps({ + accessibilityLabel: "Import from Loom", + }); + if (!uploadSource) throw new Error("Upload source button was not rendered"); + if (!loomImport) throw new Error("Loom import card was not rendered"); + if (!activeBrowseButton) + throw new Error("Browse Files button was not rendered"); + if (!photosButton) throw new Error("Photos button was not rendered"); + if (!loomAction) throw new Error("Loom upload action was not rendered"); + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + + expect(getTextNodes(renderer.toJSON())).toContain("Upload File"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Preparing upload"); + expect(getTextNodes(renderer.toJSON()).join("")).toContain( + "Preparing upload · 12 MB", + ); + expect(getTextNodes(renderer.toJSON())).toContain("Import from Loom"); + expect(getTextNodes(renderer.toJSON())).toContain( + "Finish preparing this upload before importing from Loom.", + ); + expect(uploadSource.props.accessibilityHint).toBe("Preparing upload"); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(uploadSource.props.disabled).toBe(true); + expect(resolveStyle(uploadSource.props.style)).toMatchObject({ + opacity: 0.58, + }); + expect(activeBrowseButton.props.loading).toBe(false); + expect(activeBrowseButton.props.accessibilityHint).toBe("Preparing upload"); + expect(activeBrowseButton.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(activeBrowseButton.props.disabled).toBe(true); + expect(photosButton.props.loading).toBe(false); + expect(photosButton.props.accessibilityHint).toBe("Preparing upload"); + expect(photosButton.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(photosButton.props.disabled).toBe(true); + expect(loomAction.props.loading).toBe(false); + expect(loomAction.props.accessibilityHint).toBe("Preparing upload"); + expect(loomAction.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(loomAction.props.disabled).toBe(true); + expect(loomImport.props.accessibilityHint).toBe("Preparing upload"); + expect(loomImport.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(loomImport.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loomImport.props.disabled).toBe(true); + + await act(async () => { + loomImport.props.onPress(); + }); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + + await act(async () => { + resolveUpload?.({ + id: "video_123", + } as Awaited>); + await Promise.resolve(); + }); + dateNow.mockRestore(); + }); + + it("locks inactive upload queue rows while a device upload is active", async () => { + uploadQueueState.value.items = [ + { + capId: null, + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: "Network unavailable", + fileName: "failed-upload.mp4", + folderId: null, + id: "failed-upload", + localUri: "file:///tmp/failed-upload.mp4", + organizationId: "org_123", + progress: 0.42, + rawFileKey: null, + size: 124_000, + durationSeconds: 125, + status: "failed", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + const DocumentPicker = await import("expo-document-picker"); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + let resolveUpload: + | ((value: Awaited>) => void) + | null = null; + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + mimeType: "video/mp4", + name: "launch-review.mp4", + size: 12_400_000, + uri: "file:///tmp/launch-review.mp4", + }, + ], + canceled: false, + } as Awaited>); + vi.mocked(runMobileUpload).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + void browseButton.props.onPress(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const [queueRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload failed-upload.mp4", + }); + const [retryButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Retry upload failed-upload.mp4", + }); + const queueMenus = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for failed-upload.mp4", + }); + const [queueMenu] = queueMenus; + if (!queueRow) throw new Error("Upload queue row was not rendered"); + if (!retryButton) throw new Error("Retry button was not rendered"); + if (!queueMenu) throw new Error("Upload queue menu was not rendered"); + + expect(queueRow.props.accessibilityHint).toBe( + "Another upload is in progress", + ); + expect(queueRow.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + expect(queueRow.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(queueRow.props.disabled).toBe(true); + expect(retryButton.props.disabled).toBe(true); + expect(retryButton.props.accessibilityHint).toBe( + "Another upload is in progress", + ); + expect(retryButton.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(queueMenu.props.accessibilityHint).toBe( + "Another upload is in progress", + ); + expect(queueMenu.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + expect(queueMenu.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(queueMenu.props.disabled).toBe(true); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + queueRow.props.onPress(); + retryButton.props.onPress({ stopPropagation: vi.fn() }); + queueMenu.props.onPress({ stopPropagation: vi.fn() }); + }); + + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + expect(uploadQueueActionsState.value).not.toContainEqual( + expect.objectContaining({ + id: "failed-upload", + type: "retry", + }), + ); + + await act(async () => { + resolveUpload?.({ + id: "video_123", + } as Awaited>); + await Promise.resolve(); + }); + }); + + it("locks stale upload queue view actions while a device upload is active", async () => { + uploadQueueState.value.items = [ + { + capId: "video_complete", + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: null, + fileName: "processed-upload.mp4", + folderId: null, + id: "processed-upload", + localUri: "file:///tmp/processed-upload.mp4", + organizationId: "org_123", + progress: 1, + rawFileKey: "raw-file-key", + size: 124_000, + durationSeconds: 125, + status: "complete", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [queueRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload processed-upload.mp4", + }); + const [queueMenu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for processed-upload.mp4", + }); + const [viewButton] = renderer.root.findAllByProps({ + accessibilityLabel: "View upload processed-upload.mp4", + }); + if (!queueRow) throw new Error("Upload queue row was not rendered"); + if (!queueMenu) throw new Error("Upload queue menu was not rendered"); + if (!viewButton) throw new Error("View button was not rendered"); + expect(queueRow.props.accessibilityHint).toBe( + "Ready to view. Opens upload actions", + ); + expect(queueMenu.props.accessibilityHint).toBe( + "Opens view and remove actions", + ); + expect(queueRow.props.accessibilityValue).toEqual({ + text: "Ready to view · 124 KB · 2 mins", + }); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + queueRow.props.onPress(); + }); + + const [, callback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!callback) throw new Error("Upload queue action callback was not set"); + + const DocumentPicker = await import("expo-document-picker"); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + let resolveUpload: + | ((value: Awaited>) => void) + | null = null; + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + mimeType: "video/mp4", + name: "launch-review.mp4", + size: 12_400_000, + uri: "file:///tmp/launch-review.mp4", + }, + ], + canceled: false, + } as Awaited>); + vi.mocked(runMobileUpload).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + void browseButton.props.onPress(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const { router } = await import("expo-router"); + const push = vi.mocked(router.push); + push.mockClear(); + const [lockedViewButton] = renderer.root.findAllByProps({ + accessibilityLabel: "View upload processed-upload.mp4", + }); + const [lockedQueueMenu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for processed-upload.mp4", + }); + if (!lockedViewButton) throw new Error("View button was not rendered"); + if (!lockedQueueMenu) throw new Error("Upload queue menu was not rendered"); + expect(lockedViewButton.props.accessibilityHint).toBe( + "Another upload is in progress", + ); + expect(lockedViewButton.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(lockedViewButton.props.disabled).toBe(true); + expect(lockedQueueMenu.props.accessibilityHint).toBe( + "Another upload is in progress", + ); + expect(lockedQueueMenu.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + expect(lockedQueueMenu.props.accessibilityValue).toEqual({ + text: "Preparing upload launch-review.mp4", + }); + expect(lockedQueueMenu.props.disabled).toBe(true); + + showActionSheetWithOptions.mockClear(); + await act(async () => { + viewButton.props.onPress({ stopPropagation: vi.fn() }); + lockedQueueMenu.props.onPress({ stopPropagation: vi.fn() }); + callback(0); + }); + + expect(push).not.toHaveBeenCalled(); + expect(showActionSheetWithOptions).not.toHaveBeenCalled(); + + await act(async () => { + resolveUpload?.({ + id: "video_123", + } as Awaited>); + await Promise.resolve(); + }); + }); + + it("announces processing upload queue rows with their current status", async () => { + uploadQueueState.value.items = [ + { + capId: "video_processing", + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: null, + fileName: "processing-upload.mp4", + folderId: null, + id: "processing-upload", + localUri: "file:///tmp/processing-upload.mp4", + organizationId: "org_123", + progress: 0.42, + processingMessage: "Processing frames", + rawFileKey: "raw-file-key", + size: 124_000, + durationSeconds: 125, + status: "processing", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + const renderer = await renderComponent(React.createElement(UploadScreen)); + const tree = renderer.toJSON(); + const [queueRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload processing-upload.mp4", + }); + const [queueMenu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for processing-upload.mp4", + }); + const [viewButton] = renderer.root.findAllByProps({ + accessibilityLabel: "View upload processing-upload.mp4", + }); + if (!queueRow) throw new Error("Processing upload row was not rendered"); + if (!queueMenu) throw new Error("Upload queue menu was not rendered"); + if (!viewButton) throw new Error("View button was not rendered"); + + expect(getTextNodes(tree).join("")).toContain( + "Processing frames · 124 KB · 2 mins", + ); + expect(queueRow.props.accessibilityHint).toBe( + "Processing frames. Opens upload actions", + ); + expect(queueMenu.props.accessibilityHint).toBe( + "Opens view and remove actions", + ); + expect(queueRow.props.accessibilityValue).toEqual({ + text: "Processing frames · 124 KB · 2 mins", + }); + expect( + hasProps(tree, { + accessibilityLabel: "Upload progress for processing-upload.mp4", + accessibilityRole: "progressbar", + accessibilityValue: { + max: 100, + min: 0, + now: 42, + text: "42%", + }, + }), + ).toBe(true); + expect(viewButton.props.accessibilityHint).toBe("Opens the uploaded Cap"); + }); + + it("shows queued upload rows without premature progress", async () => { + uploadQueueState.value.items = [ + { + capId: null, + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: null, + fileName: "queued-upload.mp4", + folderId: null, + id: "queued-upload", + localUri: "file:///tmp/queued-upload.mp4", + organizationId: "org_123", + progress: 0, + processingMessage: null, + rawFileKey: null, + size: 124_000, + durationSeconds: 125, + status: "queued", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + const renderer = await renderComponent(React.createElement(UploadScreen)); + const tree = renderer.toJSON(); + const [queueRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload queued-upload.mp4", + }); + const [queueMenu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for queued-upload.mp4", + }); + if (!queueRow) throw new Error("Queued upload row was not rendered"); + if (!queueMenu) throw new Error("Upload queue menu was not rendered"); + + expect(getTextNodes(tree).join("")).toContain("Queued · 124 KB · 2 mins"); + expect(queueRow.props.accessibilityHint).toBe( + "Queued. Opens upload actions", + ); + expect(queueRow.props.accessibilityValue).toEqual({ + text: "Queued · 124 KB · 2 mins", + }); + expect(queueMenu.props.accessibilityHint).toBe("Opens remove action"); + expect( + hasProps(tree, { + accessibilityLabel: "Upload progress for queued-upload.mp4", + accessibilityRole: "progressbar", + }), + ).toBe(false); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + queueMenu.props.onPress({ stopPropagation: vi.fn() }); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 1, + destructiveButtonIndex: 0, + message: "Queued · 124 KB · 2 mins", + options: ["Remove from Queue", "Cancel"], + title: "queued-upload.mp4", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + }); + + it("keeps uploaded files processing when the library refresh fails", async () => { + const auth = createAuth(); + auth.refresh = vi.fn(() => Promise.reject(new Error("Refresh failed"))); + authState.value = auth; + const DocumentPicker = await import("expo-document-picker"); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + mimeType: "video/mp4", + name: "launch-review.mp4", + size: 12_400_000, + uri: "file:///tmp/launch-review.mp4", + }, + ], + canceled: false, + } as Awaited>); + vi.mocked(runMobileUpload).mockResolvedValueOnce({ + id: "video_123", + } as Awaited>); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + await browseButton.props.onPress(); + await Promise.resolve(); + }); + + expect(uploadQueueActionsState.value).toContainEqual( + expect.objectContaining({ + progress: 0, + type: "processing", + }), + ); + expect( + uploadQueueActionsState.value.some( + (action) => + typeof action === "object" && + action !== null && + "type" in action && + action.type === "fail", + ), + ).toBe(false); + }); + + it("completes uploaded files when the final processing refresh fails", async () => { + vi.useFakeTimers(); + try { + const auth = createAuth(); + auth.refresh = vi.fn(() => Promise.reject(new Error("Refresh failed"))); + auth.client.getCap = vi.fn(() => + Promise.resolve({ + cap: { + upload: null, + }, + }), + ); + authState.value = auth; + const DocumentPicker = await import("expo-document-picker"); + const { runMobileUpload } = await import("@/uploads/runMobileUpload"); + vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({ + assets: [ + { + mimeType: "video/mp4", + name: "launch-review.mp4", + size: 12_400_000, + uri: "file:///tmp/launch-review.mp4", + }, + ], + canceled: false, + } as Awaited>); + vi.mocked(runMobileUpload).mockResolvedValueOnce({ + id: "video_123", + } as Awaited>); + const renderer = await renderComponent(React.createElement(UploadScreen)); + const [browseButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) + throw new Error("Browse Files button was not rendered"); + + await act(async () => { + await browseButton.props.onPress(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(auth.client.getCap).toHaveBeenCalledWith("video_123"); + expect(auth.refresh).toHaveBeenCalledTimes(2); + expect(uploadQueueActionsState.value).toContainEqual( + expect.objectContaining({ + type: "complete", + }), + ); + expect( + uploadQueueActionsState.value.some( + (action) => + typeof action === "object" && + action !== null && + "type" in action && + action.type === "fail", + ), + ).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("opens the native iOS upload queue sheet", async () => { + uploadQueueState.value.items = [ + { + capId: null, + contentType: "video/mp4", + createdAt: "2026-05-18T10:00:00.000Z", + error: "Network unavailable", + fileName: "failed-upload.mp4", + folderId: null, + id: "failed-upload", + localUri: "file:///tmp/failed-upload.mp4", + organizationId: "org_123", + progress: 0.42, + rawFileKey: null, + size: 124_000, + durationSeconds: 125, + status: "failed", + updatedAt: "2026-05-18T10:00:00.000Z", + }, + ]; + const renderer = await renderComponent(React.createElement(UploadScreen)); + const tree = renderer.toJSON(); + const [queueMenu] = renderer.root.findAllByProps({ + accessibilityLabel: "More actions for failed-upload.mp4", + }); + if (!queueMenu) throw new Error("Upload queue menu was not rendered"); + const [queueRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Upload failed-upload.mp4", + }); + if (!queueRow) throw new Error("Upload queue row was not rendered"); + expect(getTextNodes(tree).join("")).toContain( + "Upload failed · Network unavailable · 124 KB · 2 mins", + ); + expect(queueMenu.props.hitSlop).toBe(6); + expect(queueMenu.props.accessibilityHint).toBe( + "Opens retry and remove actions", + ); + expect( + hasProps(tree, { + accessibilityHint: "Upload failed. Opens upload actions", + accessibilityLabel: "Upload failed-upload.mp4", + accessibilityState: { busy: false, disabled: false }, + }), + ).toBe(true); + expect(queueRow.props.accessibilityValue).toEqual({ + text: "Upload failed · Network unavailable · 124 KB · 2 mins", + }); + expect( + hasProps(tree, { + accessibilityLabel: "Upload progress for failed-upload.mp4", + accessibilityRole: "progressbar", + }), + ).toBe(false); + expect(hasProp(tree, "accessibilityRole", "alert")).toBe(true); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + const menuStopPropagation = vi.fn(); + await act(async () => { + queueMenu.props.onPress({ stopPropagation: menuStopPropagation }); + }); + + expect(menuStopPropagation).toHaveBeenCalled(); + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + message: "Upload failed · Network unavailable · 124 KB · 2 mins", + options: ["Retry", "Remove from Queue", "Cancel"], + title: "failed-upload.mp4", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + queueRow.props.onPress(); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 2, + destructiveButtonIndex: 1, + message: "Upload failed · Network unavailable · 124 KB · 2 mins", + options: ["Retry", "Remove from Queue", "Cancel"], + title: "failed-upload.mp4", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + }); + + it("announces picker errors as native alerts", async () => { + const DocumentPicker = await import("expo-document-picker"); + vi.mocked(DocumentPicker.getDocumentAsync).mockRejectedValueOnce( + new Error("Files unavailable"), + ); + const uploadRenderer = await renderComponent( + React.createElement(UploadScreen), + ); + const [browseButton] = uploadRenderer.root.findAllByProps({ + accessibilityLabel: "Browse Files", + }); + if (!browseButton) throw new Error("Browse Files button was not rendered"); + + await act(async () => { + await browseButton.props.onPress(); + }); + + expect(getTextNodes(uploadRenderer.toJSON())).toContain( + "Upload source unavailable", + ); + expect(getTextNodes(uploadRenderer.toJSON())).toContain( + "Files unavailable", + ); + const [uploadSource] = uploadRenderer.root.findAllByProps({ + accessibilityLabel: "Upload source unavailable", + }); + if (!uploadSource) throw new Error("Upload source button was not rendered"); + expect(uploadSource.props.accessibilityHint).toBe( + "Retries upload source options", + ); + expect(uploadSource.props.accessibilityValue).toEqual({ + text: "Files unavailable", + }); + const [retryFilesButton] = uploadRenderer.root.findAllByProps({ + accessibilityLabel: "Retry Files", + }); + if (!retryFilesButton) + throw new Error("Retry Files button was not rendered"); + expect(retryFilesButton.props.accessibilityHint).toBe("Files unavailable"); + expect(retryFilesButton.props.disabled).toBe(false); + const [loomImport] = uploadRenderer.root.findAllByProps({ + accessibilityLabel: "Open Loom import", + }); + if (!loomImport) throw new Error("Loom import card was not rendered"); + expect(loomImport.props.accessibilityValue).toBeUndefined(); + expect(hasStyle(uploadRenderer.toJSON(), { color: "#e5484d" })).toBe(true); + expect( + hasProps(uploadRenderer.toJSON(), { + accessibilityLiveRegion: "polite", + accessibilityRole: "alert", + }), + ).toBe(true); + }); + + it("shows dashboard import actions", async () => { + const tree = await renderTree(React.createElement(CapsScreen)); + const text = getTextNodes(tree); + + expect(text).toContain("My Caps"); + expect(text.filter((item) => item === "New Folder").length).toBeGreaterThan( + 0, + ); + expect(text).not.toContain("Record"); + expect( + text.filter((item) => item === "Import Video").length, + ).toBeGreaterThan(0); + expect(hasStyle(tree, { marginBottom: 40 })).toBe(true); + expect(text.join("")).toContain("Hey Richie! Import your first Cap"); + expect(hasProp(tree, "accessibilityLabel", "Cap logo")).toBe(true); + expect( + hasProps(tree, { + accessibilityHint: "Opens import options", + accessibilityLabel: "Import Video", + }), + ).toBe(true); + }); + + it("announces dashboard load errors with a retry action", async () => { + const auth = createAuth(); + auth.client.listCaps = vi.fn(() => + Promise.reject(new Error("Network unavailable")), + ); + authState.value = auth; + const renderer = await renderComponent(React.createElement(CapsScreen)); + + await act(async () => { + await Promise.resolve(); + }); + + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(text).toContain("Unable to load Caps"); + expect(text).toContain("Network unavailable"); + expect( + hasProps(tree, { + accessibilityLabel: "Library error: Network unavailable", + accessibilityLiveRegion: "polite", + accessibilityRole: "alert", + }), + ).toBe(true); + + const [retryButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Try again", + }); + if (!retryButton) + throw new Error("Dashboard retry action was not rendered"); + expect(retryButton.props.accessibilityHint).toBe( + "Reloads your Cap library", + ); + + await act(async () => { + await retryButton.props.onPress(); + await Promise.resolve(); + }); + + expect(auth.client.listCaps).toHaveBeenCalledTimes(2); + }); + + it("renders dashboard folders with native folder rows", async () => { + const auth = createAuth(); + auth.client.listCaps = () => + Promise.resolve({ + caps: [], + folders: [ + { + color: "blue", + id: "folder_123", + name: "Product", + parentId: null, + videoCount: 2, + }, + ], + pagination: { + hasNextPage: false, + page: 1, + totalPages: 1, + }, + rootFolders: [], + }); + authState.value = auth; + + const renderer = await renderComponent(React.createElement(CapsScreen)); + await act(async () => { + await Promise.resolve(); + }); + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(text).toContain("Folders"); + expect(text).toContain("Product"); + expect(text.join("")).toContain("2 videos"); + expect(hasStyle(tree, { paddingBottom: 24 })).toBe(true); + expect(hasProp(tree, "accessibilityLabel", "Open folder Product")).toBe( + true, + ); + + const [folderRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Open folder Product", + }); + if (!folderRow) throw new Error("Folder row was not rendered"); + + expect(resolveStyle(folderRow.props.style)).toMatchObject({ + backgroundColor: "#f0f0f0", + borderColor: "#e0e0e0", + }); + expect(resolveStyle(folderRow.props.style, true)).toMatchObject({ + backgroundColor: "#e8e8e8", + borderColor: "#d9d9d9", + }); + + await act(async () => { + folderRow.props.onPress(); + }); + + expect( + hasProp(renderer.toJSON(), "accessibilityLabel", "Back to My Caps"), + ).toBe(true); + }); + + it("marks the dashboard card sharing action busy while visibility is updating", async () => { + const auth = createAuth(); + const cap = { + commentCount: 2, + createdAt: "2026-05-18T10:00:00.000Z", + durationSeconds: 125, + folderId: null, + id: "video_123", + ownerName: "Richie", + protected: false, + public: true, + reactionCount: 3, + shareUrl: "https://cap.so/s/video_123", + thumbnailUrl: null, + title: "Launch review", + updatedAt: "2026-05-18T10:30:00.000Z", + upload: null, + viewCount: 7, + }; + const sharingDeferred = createDeferred(); + auth.client.listCaps = vi.fn(() => + Promise.resolve({ + caps: [cap], + folders: [], + pagination: { + hasNextPage: false, + page: 1, + totalPages: 1, + }, + rootFolders: [], + }), + ); + auth.client.updateCapSharing = vi.fn(() => sharingDeferred.promise); + authState.value = auth; + + const renderer = await renderComponent(React.createElement(CapsScreen)); + await act(async () => { + await Promise.resolve(); + }); + const [capCard] = renderer.root.findAllByProps({ cap }); + if (!capCard) throw new Error("Cap card was not rendered"); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + capCard.props.onVisibilityPress(); + }); + + const [, sharingCallback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!sharingCallback) throw new Error("Sharing callback was not set"); + + await act(async () => { + sharingCallback(0); + await Promise.resolve(); + }); + + const [busyCard] = renderer.root.findAllByProps({ cap }); + if (!busyCard) throw new Error("Busy Cap card was not rendered"); + + expect(auth.client.updateCapSharing).toHaveBeenCalledWith("video_123", { + public: false, + }); + expect(busyCard.props.visibilityBusy).toBe(true); + expect(busyCard.props.visibilityDisabled).toBe(true); + expect(busyCard.props.visibilityDisabledHint).toBe( + "Sharing update is in progress", + ); + expect(busyCard.props.visibilityValue).toBeUndefined(); + expect(busyCard.props.visibilityAccessibilityValue).toBe( + "Updating sharing for Launch review", + ); + + await act(async () => { + sharingDeferred.resolve({ ...cap, public: false }); + await sharingDeferred.promise; + await Promise.resolve(); + }); + }); + + it("opens the native iOS folder creation prompt and color sheet", async () => { + const auth = createAuth(); + authState.value = auth; + const renderer = await renderComponent(React.createElement(CapsScreen)); + const [newFolder] = renderer.root.findAllByProps({ + accessibilityLabel: "New Folder", + }); + if (!newFolder) throw new Error("New Folder action was not rendered"); + + const { ActionSheetIOS, Alert } = await import("react-native"); + const prompt = vi.mocked(Alert.prompt); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + prompt.mockClear(); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + newFolder.props.onPress(); + }); + + expect(prompt).toHaveBeenCalledWith( + "New Folder", + "Name this folder.", + expect.any(Array), + "plain-text", + ); + + const buttons = prompt.mock.calls[0]?.[2] as + | Array<{ onPress?: (value?: string) => void }> + | undefined; + if (!Array.isArray(buttons)) { + throw new Error("Folder prompt buttons were not provided"); + } + const nextButton = buttons[1]; + const nextAction = nextButton?.onPress; + if (typeof nextAction !== "function") { + throw new Error("Folder prompt next action was not provided"); + } + + await act(async () => { + nextAction("Product"); + }); + + expect(showActionSheetWithOptions).toHaveBeenCalledWith( + expect.objectContaining({ + cancelButtonIndex: 4, + message: "Product", + options: ["Normal", "Blue", "Red", "Yellow", "Cancel"], + title: "Folder color", + userInterfaceStyle: "light", + }), + expect.any(Function), + ); + + const [, colorCallback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!colorCallback) throw new Error("Folder color callback was not set"); + + await act(async () => { + colorCallback(1); + await Promise.resolve(); + }); + + expect(auth.client.createFolder).toHaveBeenCalledWith({ + name: "Product", + color: "blue", + }); + }); + + it("locks dashboard navigation while a folder is being created", async () => { + const auth = createAuth(); + const folderDeferred = + createDeferred>>(); + auth.client.createFolder = vi.fn(() => folderDeferred.promise); + authState.value = auth; + const renderer = await renderComponent(React.createElement(CapsScreen)); + const [newFolder] = renderer.root.findAllByProps({ + accessibilityLabel: "New Folder", + }); + if (!newFolder) throw new Error("New Folder action was not rendered"); + + const { ActionSheetIOS, Alert } = await import("react-native"); + const prompt = vi.mocked(Alert.prompt); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + prompt.mockClear(); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + newFolder.props.onPress(); + }); + + const buttons = prompt.mock.calls[0]?.[2] as + | Array<{ onPress?: (value?: string) => void }> + | undefined; + const nextAction = buttons?.[1]?.onPress; + if (typeof nextAction !== "function") { + throw new Error("Folder prompt next action was not provided"); + } + + await act(async () => { + nextAction("Product"); + }); + + const [, colorCallback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!colorCallback) throw new Error("Folder color callback was not set"); + + await act(async () => { + colorCallback(1); + await Promise.resolve(); + }); + + const [creatingFolder] = renderer.root.findAllByProps({ + accessibilityLabel: "New Folder", + }); + if (!creatingFolder) { + throw new Error("Creating folder action was not rendered"); + } + expect(getTextNodes(renderer.toJSON())).not.toContain("Creating..."); + expect(creatingFolder.props.loading).toBe(true); + expect(creatingFolder.props.accessibilityHint).toBe( + "Folder creation is in progress", + ); + expect(creatingFolder.props.accessibilityValue).toEqual({ + text: "Creating folder Product", + }); + for (const action of renderer.root.findAllByProps({ + accessibilityLabel: "Import Video", + })) { + expect(action.props.disabled).toBe(true); + expect(action.props.accessibilityHint).toBe( + "Folder creation is in progress", + ); + expect(action.props.accessibilityValue).toEqual({ + text: "Creating folder Product", + }); + } + + await act(async () => { + folderDeferred.resolve({ + id: "folder_123", + name: "Product", + color: "blue", + parentId: null, + videoCount: 0, + }); + await folderDeferred.promise; + await Promise.resolve(); + }); + }); +}); From 16919d02cdc75903a351f2c18dc6e32d0dd15b81 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 15/20] feat(mobile): add upload tab screen --- apps/mobile/app/(tabs)/upload.tsx | 1254 +++++++++++++++++++++++++++++ 1 file changed, 1254 insertions(+) create mode 100644 apps/mobile/app/(tabs)/upload.tsx diff --git a/apps/mobile/app/(tabs)/upload.tsx b/apps/mobile/app/(tabs)/upload.tsx new file mode 100644 index 0000000000..aa790e62a9 --- /dev/null +++ b/apps/mobile/app/(tabs)/upload.tsx @@ -0,0 +1,1254 @@ +import * as DocumentPicker from "expo-document-picker"; +import * as ImagePicker from "expo-image-picker"; +import { router } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import * as WebBrowser from "expo-web-browser"; +import { useEffect, useReducer, useRef, useState } from "react"; +import { + ActionSheetIOS, + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import Svg, { Path } from "react-native-svg"; +import type { UploadFile } from "@/api/mobile"; +import { apiBaseUrl, useAuth } from "@/auth/AuthContext"; +import { SignInPanel } from "@/auth/SignInPanel"; +import { ActionButton } from "@/components/ActionButton"; +import { GlassSurface } from "@/components/GlassSurface"; +import { Screen } from "@/components/Screen"; +import { colors, fonts, radius, squircle } from "@/theme"; +import { contentTypeForUpload } from "@/uploads/fileTypes"; +import { runMobileUpload } from "@/uploads/runMobileUpload"; +import { + emptyUploadQueue, + isTerminalUploadQueueAction, + type UploadQueueItem, + uploadProgressPercent, + uploadQueueActionFromCapUpload, + uploadQueueReducer, + uploadQueueStatusText, +} from "@/uploads/uploadQueue"; +import { formatDuration, formatFileSize } from "@/utils/format"; + +const processingPollDelaysMs = [1500, 3000, 5000, 8000] as const; +const photosAccessNeededMessage = + "Allow Cap to read videos from Photos before uploading."; +const uploadAcceptedFormats = "MP4, MOV, AVI, MKV, WebM, or M4V"; +type UploadSourceLoading = "files" | "loom" | "photos" | null; +type UploadSource = Exclude; +type UploadSourceError = { + message: string; + source: UploadSource; +}; + +const queueItemFromFile = ( + file: UploadFile, + organizationId: string | null, +): Omit => ({ + id: `${Date.now()}-${file.name}`, + localUri: file.uri, + fileName: file.name, + contentType: file.type, + size: file.size ?? 0, + durationSeconds: file.durationSeconds, + width: file.width, + height: file.height, + folderId: null, + organizationId, + status: "queued", + progress: 0, + error: null, + capId: null, + rawFileKey: null, + processingMessage: null, +}); + +const showPhotosSettingsAlert = () => { + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + message: photosAccessNeededMessage, + options: ["Open Settings", "Cancel"], + title: "Photos access needed", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) void Linking.openSettings(); + }, + ); + return; + } + + Alert.alert("Photos access needed", photosAccessNeededMessage, [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => { + void Linking.openSettings(); + }, + }, + ]); +}; + +const getUploadSourceErrorMessage = (error: unknown, source: UploadSource) => + error instanceof Error + ? error.message + : source === "loom" + ? "Unable to open Loom import" + : "Unable to open the picker"; + +const uploadQueueMetadataText = ( + item: UploadQueueItem, + statusText = uploadQueueStatusText(item), +) => { + const failureReason = + item.status === "failed" && item.error?.trim() ? item.error.trim() : null; + return [ + statusText, + failureReason, + formatFileSize(item.size), + formatDuration(item.durationSeconds ?? null), + ] + .filter(Boolean) + .join(" · "); +}; + +const uploadQueueMenuHint = (item: UploadQueueItem) => { + if (item.status === "failed") return "Opens retry and remove actions"; + if ( + (item.status === "processing" || item.status === "complete") && + item.capId + ) { + return "Opens view and remove actions"; + } + return "Opens remove action"; +}; + +const uploadQueueHasProgress = (item: UploadQueueItem) => + item.status === "uploading" || + item.status === "processing" || + item.status === "complete"; + +const progressAccessibilityValue = (percent: number) => ({ + max: 100, + min: 0, + now: percent, + text: `${percent}%`, +}); + +const LoomMark = () => ( + + + +); + +const idleLoomImportLabel = "Import from Loom"; + +export default function UploadScreen() { + const auth = useAuth(); + const [queue, dispatch] = useReducer(uploadQueueReducer, emptyUploadQueue); + const [activeId, setActiveId] = useState(null); + const [activeUploadName, setActiveUploadName] = useState(null); + const [sourceError, setSourceError] = useState( + null, + ); + const [sourceLoading, setSourceLoading] = useState(null); + const mountedRef = useRef(true); + const activeIdRef = useRef(null); + const sourceBusyRef = useRef(false); + const uploadSourceError = + sourceError?.source === "files" || sourceError?.source === "photos" + ? sourceError.message + : null; + const loomImportError = + sourceError?.source === "loom" ? sourceError.message : null; + const activeItem = activeId + ? (queue.items.find((item) => item.id === activeId) ?? null) + : null; + const activeUploadFileName = activeItem?.fileName ?? activeUploadName; + const activeUploadPreparing = + activeId !== null && + (activeItem === null || activeItem.status === "queued"); + const activeProgress = + activeItem !== null && uploadQueueHasProgress(activeItem) + ? uploadProgressPercent(activeItem.progress) + : null; + const activeUploadHint = activeUploadPreparing + ? "Preparing upload" + : "Upload is in progress"; + const uploadSourceBusy = activeId !== null || sourceLoading !== null; + const sourcePending = + sourceLoading !== null && sourceLoading !== "loom" && activeId === null; + const sourceLoadingTitle = + sourcePending && sourceLoading === "files" + ? "Opening Files" + : sourcePending && sourceLoading === "photos" + ? "Opening Photos" + : null; + const sourceLoadingSubtitle = + sourcePending && sourceLoading === "files" + ? "Choose a video from Files." + : sourcePending && sourceLoading === "photos" + ? "Choose a video from Photos." + : null; + const sourceLoadingAccessibilityText = + sourceLoading === "files" + ? "Opening native file picker" + : sourceLoading === "photos" + ? "Opening native photo picker" + : sourceLoading === "loom" + ? "Opening Loom import" + : null; + const importTitle = sourceLoadingTitle ?? "Upload File"; + const importSubtitle = + uploadSourceError ?? + sourceLoadingSubtitle ?? + (activeId !== null + ? activeUploadPreparing + ? "Preparing your video for upload." + : "Keep Cap open while your video uploads." + : "Upload a video file from your device"); + const loomImportTitle = loomImportError + ? "Loom import unavailable" + : sourceLoading === "loom" + ? "Opening Loom" + : idleLoomImportLabel; + const loomImportSubtitle = + loomImportError ?? + (sourceLoading === "loom" + ? "Continue in the browser sheet to import from Loom." + : activeId !== null + ? activeUploadPreparing + ? "Finish preparing this upload before importing from Loom." + : "Finish the current upload before importing from Loom." + : "Import a Loom share link or bulk import from CSV"); + const activeUploadAccessibilityLabel = activeUploadFileName + ? activeUploadPreparing + ? `Preparing upload ${activeUploadFileName}` + : activeProgress !== null + ? `Uploading ${activeUploadFileName} ${activeProgress}%` + : `Uploading ${activeUploadFileName}` + : null; + const activeUploadAccessibilityValue = activeUploadAccessibilityLabel + ? { text: activeUploadAccessibilityLabel } + : undefined; + const showUploadFormats = + !uploadSourceError && !sourcePending && activeId === null; + const uploadSourceAccessibilityLabel = sourcePending + ? (sourceLoadingTitle ?? "Upload source opening") + : uploadSourceError + ? "Upload source unavailable" + : "Choose upload source"; + const loomImportAccessibilityLabel = loomImportError + ? "Loom import unavailable" + : sourceLoading === "loom" + ? loomImportTitle + : "Open Loom import"; + const uploadSourceAccessibilityValue = uploadSourceError + ? { text: uploadSourceError } + : sourcePending && sourceLoadingAccessibilityText + ? { text: sourceLoadingAccessibilityText } + : sourceLoading === "loom" && sourceLoadingAccessibilityText + ? { text: sourceLoadingAccessibilityText } + : (activeUploadAccessibilityValue ?? { text: uploadAcceptedFormats }); + const loomImportAccessibilityValue = loomImportError + ? { text: loomImportError } + : sourceLoading !== null && sourceLoadingAccessibilityText + ? { text: sourceLoadingAccessibilityText } + : activeUploadAccessibilityValue; + const sourceOpeningHint = + sourceLoading === "loom" + ? "Loom import is opening" + : "Upload source picker is opening"; + const uploadSourceActionHint = ( + source: Exclude, + idleHint: string, + ) => { + if (activeId !== null) return activeUploadHint; + if (sourceLoading === source) return sourceOpeningHint; + if (sourceLoading !== null) { + return sourceLoading === "loom" + ? "Loom import is opening" + : "Another upload source is opening"; + } + if (sourceError?.source === source) return sourceError.message; + return idleHint; + }; + const uploadSourceActionValue = ( + source: Exclude, + ) => { + if (sourceLoading !== null && sourceLoadingAccessibilityText) { + return { text: sourceLoadingAccessibilityText }; + } + if (sourceError?.source === source) return { text: sourceError.message }; + if (activeId !== null) { + return activeUploadAccessibilityValue; + } + return undefined; + }; + const browseFilesLabel = + sourceError?.source === "files" ? "Retry Files" : "Browse Files"; + const photosLabel = + sourceError?.source === "photos" ? "Retry Photos" : "Photos"; + const loomActionLabel = + sourceError?.source === "loom" ? "Retry Loom" : "Loom"; + const loomActionAccessibilityLabel = + sourceError?.source === "loom" ? undefined : idleLoomImportLabel; + const uploadSourceCardBusy = activeId !== null || sourcePending; + + useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); + + const dispatchIfMounted = (action: Parameters[0]) => { + if (mountedRef.current) dispatch(action); + }; + + const setActiveUploadId = (id: string | null, fileName?: string) => { + activeIdRef.current = id; + setActiveId(id); + setActiveUploadName(id ? (fileName ?? null) : null); + }; + + const isUploadSourceBusy = () => + sourceBusyRef.current || activeIdRef.current !== null; + + const beginUploadSource = (source: UploadSource) => { + if (isUploadSourceBusy()) return false; + sourceBusyRef.current = true; + setSourceError(null); + setSourceLoading(source); + return true; + }; + + const endUploadSource = () => { + sourceBusyRef.current = false; + setSourceLoading(null); + }; + + const waitForProcessing = async (queueItemId: string, capId: string) => { + if (auth.status !== "signedIn") return; + + for (const delayMs of processingPollDelaysMs) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (!mountedRef.current) return; + + try { + const detail = await auth.client.getCap(capId); + const action = uploadQueueActionFromCapUpload( + queueItemId, + detail.cap.upload, + ); + if (action) { + dispatchIfMounted(action); + if (isTerminalUploadQueueAction(action)) { + if (action.type === "complete") { + await auth.refresh().catch(() => undefined); + } + return; + } + } + } catch { + return; + } + } + }; + + const uploadQueueItem = async ( + item: Omit | UploadQueueItem, + file: UploadFile, + ) => { + if (auth.status !== "signedIn") return; + + setActiveUploadId(item.id, item.fileName); + try { + const created = await runMobileUpload({ + client: auth.client, + file, + organizationId: + item.organizationId ?? auth.bootstrap?.activeOrganizationId, + folderId: item.folderId, + onCreated: (capId, rawFileKey) => + dispatch({ + type: "start", + id: item.id, + capId, + rawFileKey, + }), + onProgress: (progress) => + dispatch({ type: "progress", id: item.id, progress }), + }); + dispatch({ type: "processing", id: item.id, progress: 0 }); + await auth.refresh().catch(() => undefined); + void waitForProcessing(item.id, created.id); + } catch (error) { + dispatch({ + type: "fail", + id: item.id, + error: error instanceof Error ? error.message : "Upload failed", + }); + } finally { + setActiveUploadId(null); + } + }; + + const uploadFile = async (file: UploadFile) => { + if (auth.status !== "signedIn") return; + setSourceError(null); + + const item = queueItemFromFile( + file, + auth.bootstrap?.activeOrganizationId ?? null, + ); + dispatch({ type: "enqueue", item }); + await uploadQueueItem(item, file); + }; + + const pickFile = async () => { + if (!beginUploadSource("files")) return; + try { + const result = await DocumentPicker.getDocumentAsync({ + type: "video/*", + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets[0]) return; + const asset = result.assets[0]; + endUploadSource(); + await uploadFile({ + uri: asset.uri, + name: asset.name, + type: contentTypeForUpload(asset.name, asset.mimeType), + size: asset.size, + }); + } catch (error) { + setSourceError({ + message: getUploadSourceErrorMessage(error, "files"), + source: "files", + }); + } finally { + endUploadSource(); + } + }; + + const pickPhoto = async () => { + if (!beginUploadSource("photos")) return; + try { + const permission = + await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + setSourceError({ + message: photosAccessNeededMessage, + source: "photos", + }); + showPhotosSettingsAlert(); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["videos"], + allowsEditing: false, + }); + if (result.canceled || !result.assets[0]) return; + const asset = result.assets[0]; + const name = asset.fileName ?? `Cap Upload ${Date.now()}.mov`; + endUploadSource(); + await uploadFile({ + uri: asset.uri, + name, + type: contentTypeForUpload(name, asset.mimeType), + size: asset.fileSize, + durationSeconds: + typeof asset.duration === "number" && asset.duration > 0 + ? asset.duration / 1000 + : undefined, + width: asset.width > 0 ? asset.width : undefined, + height: asset.height > 0 ? asset.height : undefined, + }); + } catch (error) { + setSourceError({ + message: getUploadSourceErrorMessage(error, "photos"), + source: "photos", + }); + } finally { + endUploadSource(); + } + }; + + const retry = async (item: UploadQueueItem) => { + if (activeIdRef.current !== null) return; + dispatch({ type: "retry", id: item.id }); + await uploadQueueItem(item, { + uri: item.localUri, + name: item.fileName, + type: item.contentType, + size: item.size, + durationSeconds: item.durationSeconds, + width: item.width, + height: item.height, + }); + }; + + const viewCap = (capId: string | null) => { + if (activeIdRef.current !== null) return; + if (!capId) return; + router.push(`/caps/${capId}`); + }; + + const removeQueueItem = (item: UploadQueueItem) => { + if (activeIdRef.current !== null) return; + dispatch({ type: "remove", id: item.id }); + }; + + const showQueueItemActions = (item: UploadQueueItem) => { + if (activeIdRef.current !== null) return; + const actions: Array<{ + label: string; + destructive?: boolean; + onPress: () => void; + }> = []; + + if (item.status === "failed") { + actions.push({ + label: "Retry", + onPress: () => { + void retry(item); + }, + }); + } + + if ( + (item.status === "processing" || item.status === "complete") && + item.capId + ) { + actions.push({ + label: "View", + onPress: () => viewCap(item.capId), + }); + } + + actions.push({ + label: "Remove from Queue", + destructive: true, + onPress: () => removeQueueItem(item), + }); + + if (Platform.OS === "ios") { + const cancelButtonIndex = actions.length; + const destructiveButtonIndex = actions.findIndex( + (action) => action.destructive, + ); + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex, + destructiveButtonIndex: + destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined, + message: uploadQueueMetadataText(item), + options: [...actions.map((action) => action.label), "Cancel"], + title: item.fileName, + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + actions[index]?.onPress(); + }, + ); + return; + } + + Alert.alert(item.fileName, uploadQueueMetadataText(item), [ + ...actions.map((action) => ({ + text: action.label, + style: action.destructive ? ("destructive" as const) : undefined, + onPress: action.onPress, + })), + { text: "Cancel", style: "cancel" }, + ]); + }; + + const showUploadSources = () => { + if (isUploadSourceBusy()) return; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + options: ["Browse Files", "Photos", idleLoomImportLabel, "Cancel"], + cancelButtonIndex: 3, + message: uploadAcceptedFormats, + title: "Upload File", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) void pickFile(); + if (index === 1) void pickPhoto(); + if (index === 2) void openLoomImport(); + }, + ); + return; + } + + Alert.alert("Upload File", "Choose a video source.", [ + { text: "Browse Files", onPress: () => void pickFile() }, + { text: "Photos", onPress: () => void pickPhoto() }, + { text: idleLoomImportLabel, onPress: () => void openLoomImport() }, + { text: "Cancel", style: "cancel" }, + ]); + }; + + const openLoomImport = async () => { + if (!beginUploadSource("loom")) return; + + try { + const url = new URL("/dashboard/import/loom", apiBaseUrl); + await WebBrowser.openBrowserAsync(url.toString()); + } catch (error) { + setSourceError({ + message: getUploadSourceErrorMessage(error, "loom"), + source: "loom", + }); + } finally { + endUploadSource(); + } + }; + + if (auth.status === "loading") { + return ; + } + + if (auth.status === "signedOut") { + return ( + + + + ); + } + + return ( + + + [ + styles.importPressable, + uploadSourceBusy && styles.importPressableDisabled, + pressed && !uploadSourceBusy && styles.importPressablePressed, + ]} + > + + + {uploadSourceCardBusy ? ( + + ) : uploadSourceError ? ( + + ) : ( + + )} + + + + + + {uploadSourceError ? "Upload source unavailable" : importTitle} + + + {importSubtitle} + + {showUploadFormats ? ( + {uploadAcceptedFormats} + ) : null} + {activeProgress !== null ? ( + + + + ) : null} + + + + + + + { + void openLoomImport(); + }} + variant="gray" + loading={sourceLoading === "loom"} + disabled={uploadSourceBusy && sourceLoading !== "loom"} + style={styles.actionButton} + size="sm" + leading={} + /> + + + + { + void openLoomImport(); + }} + style={({ pressed }) => [ + styles.importPressable, + uploadSourceBusy && styles.importPressableDisabled, + pressed && !uploadSourceBusy && styles.importPressablePressed, + ]} + > + + + {loomImportError ? ( + + ) : ( + + )} + + + + + {loomImportTitle} + + {loomImportSubtitle} + + + + + + + Queue + {queue.items.length === 0 ? ( + + + No uploads yet + + ) : ( + + {queue.items + .slice() + .reverse() + .map((item, index, items) => { + const isActiveQueueItem = activeId === item.id; + const queueActionsDisabled = activeId !== null; + const queueProgress = uploadProgressPercent(item.progress); + const showQueueProgress = uploadQueueHasProgress(item); + const queueStatus = uploadQueueStatusText(item); + const queueDisplayStatus = + isActiveQueueItem && item.status === "queued" + ? "Preparing upload" + : queueStatus; + const queueMetadata = uploadQueueMetadataText( + item, + queueDisplayStatus, + ); + const queueAccessibilityValue = + queueActionsDisabled && activeUploadAccessibilityValue + ? activeUploadAccessibilityValue + : { text: queueMetadata }; + const queueHint = isActiveQueueItem + ? item.status === "queued" + ? "Preparing upload" + : "Upload is in progress" + : queueActionsDisabled + ? "Another upload is in progress" + : `${queueStatus}. Opens upload actions`; + const queueMenuHint = queueActionsDisabled + ? queueHint + : uploadQueueMenuHint(item); + return ( + + showQueueItemActions(item)} + onPress={() => showQueueItemActions(item)} + style={({ pressed }) => [ + styles.queueItem, + queueActionsDisabled && + !isActiveQueueItem && + styles.queueItemDisabled, + pressed && activeId === null && styles.queueItemPressed, + ]} + > + + + {item.fileName} + + {queueMetadata} + {showQueueProgress ? ( + + + + ) : null} + {item.error ? ( + + {item.error} + + ) : null} + + {item.status === "failed" ? ( + { + event?.stopPropagation(); + void retry(item); + }} + disabled={queueActionsDisabled} + size="sm" + style={styles.viewButton} + symbol="arrow.clockwise" + variant="secondary" + /> + ) : (item.status === "processing" || + item.status === "complete") && + item.capId ? ( + { + event?.stopPropagation(); + viewCap(item.capId); + }} + disabled={queueActionsDisabled} + size="sm" + style={styles.viewButton} + symbol="play.rectangle" + variant="secondary" + /> + ) : null} + { + event.stopPropagation(); + if (queueActionsDisabled) return; + showQueueItemActions(item); + }} + style={({ pressed }) => [ + styles.queueMenuButton, + queueActionsDisabled && + styles.queueMenuButtonDisabled, + pressed && + !queueActionsDisabled && + styles.queueMenuButtonPressed, + ]} + > + + + + {index < items.length - 1 ? ( + + ) : null} + + ); + })} + + )} + + + ); +} + +const styles = StyleSheet.create({ + importCard: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + overflow: "hidden", + marginBottom: 20, + ...squircle, + }, + importCardFallback: { + backgroundColor: colors.gray1, + }, + importPressable: { + width: "100%", + }, + importPressablePressed: { + backgroundColor: colors.gray2, + }, + importPressableDisabled: { + opacity: 0.58, + }, + importPreview: { + height: 128, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + }, + importIcon: { + width: 56, + height: 56, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray1, + ...squircle, + }, + importBody: { + padding: 16, + }, + importCopy: { + gap: 4, + }, + importTitle: { + fontFamily: fonts.medium, + fontSize: 14, + lineHeight: 20, + color: colors.gray12, + }, + importSubtitle: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 16, + color: colors.gray10, + }, + importMeta: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 16, + color: colors.gray9, + }, + importErrorSubtitle: { + color: colors.red9, + }, + importProgressTrack: { + height: 5, + borderRadius: radius.full, + backgroundColor: colors.gray4, + overflow: "hidden", + marginTop: 10, + ...squircle, + }, + importProgressFill: { + height: "100%", + borderRadius: radius.full, + backgroundColor: colors.buttonBlue, + }, + actions: { + flexDirection: "row", + gap: 10, + width: "100%", + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.gray3, + padding: 12, + }, + actionButton: { + flex: 1, + }, + queue: { + gap: 10, + }, + sectionTitle: { + fontFamily: fonts.medium, + fontSize: 18, + color: colors.gray12, + }, + empty: { + height: 124, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + backgroundColor: colors.gray1, + alignItems: "center", + justifyContent: "center", + gap: 8, + ...squircle, + }, + emptyText: { + fontFamily: fonts.medium, + color: colors.gray10, + }, + queueGroup: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + overflow: "hidden", + ...squircle, + }, + queueGroupFallback: { + backgroundColor: colors.gray1, + }, + queueItem: { + minHeight: 78, + padding: 12, + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + queueItemPressed: { + backgroundColor: colors.gray2, + }, + queueItemDisabled: { + opacity: 0.58, + }, + queueSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.gray4, + marginLeft: 12, + }, + queueText: { + flex: 1, + minWidth: 0, + gap: 3, + }, + fileName: { + fontFamily: fonts.medium, + fontSize: 16, + color: colors.gray12, + }, + fileMeta: { + fontFamily: fonts.regular, + fontSize: 13, + color: colors.gray10, + }, + errorText: { + fontFamily: fonts.regular, + fontSize: 12, + color: colors.red9, + }, + viewButton: { + width: 88, + }, + queueMenuButton: { + width: 42, + height: 42, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray2, + ...squircle, + }, + queueMenuButtonPressed: { + backgroundColor: colors.gray4, + }, + queueMenuButtonDisabled: { + backgroundColor: colors.gray3, + }, + progressTrack: { + height: 4, + borderRadius: radius.full, + backgroundColor: colors.gray4, + overflow: "hidden", + marginTop: 5, + }, + progressFill: { + height: "100%", + borderRadius: radius.full, + backgroundColor: colors.buttonBlue, + }, +}); From 718a50ea18b14e236956b3b2491fb20798546a1d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 16/20] feat(mobile): add account settings tab screen --- apps/mobile/app/(tabs)/account.tsx | 495 ++++++++++++++++++ .../src/screens/account-settings.test.tsx | 377 +++++++++++++ 2 files changed, 872 insertions(+) create mode 100644 apps/mobile/app/(tabs)/account.tsx create mode 100644 apps/mobile/src/screens/account-settings.test.tsx diff --git a/apps/mobile/app/(tabs)/account.tsx b/apps/mobile/app/(tabs)/account.tsx new file mode 100644 index 0000000000..7bb19f2b05 --- /dev/null +++ b/apps/mobile/app/(tabs)/account.tsx @@ -0,0 +1,495 @@ +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { type SFSymbol, SymbolView } from "expo-symbols"; +import * as WebBrowser from "expo-web-browser"; +import { type ReactNode, useRef, useState } from "react"; +import { + ActionSheetIOS, + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { apiBaseUrl, useAuth } from "@/auth/AuthContext"; +import { SignInPanel } from "@/auth/SignInPanel"; +import { GlassSurface } from "@/components/GlassSurface"; +import { OrgSwitcher } from "@/components/OrgSwitcher"; +import { Screen } from "@/components/Screen"; +import { colors, fonts, radius, squircle } from "@/theme"; + +type SettingsRowProps = { + label: string; + symbol: SFSymbol; + onPress?: () => void; + tintColor?: string; + destructive?: boolean; + value?: string; + accessibilityValueText?: string; + showChevron?: boolean; + accessibilityHint?: string; + busy?: boolean; + disabled?: boolean; +}; + +function SettingsRow({ + label, + symbol, + onPress, + tintColor = colors.gray12, + destructive = false, + value, + accessibilityValueText, + showChevron = true, + accessibilityHint, + busy = false, + disabled = false, +}: SettingsRowProps) { + const accessibilityValue = accessibilityValueText + ? { text: accessibilityValueText } + : value + ? { text: value } + : undefined; + const isAction = Boolean(onPress); + const isDisabled = disabled || busy; + const content = ( + <> + + {busy ? ( + + ) : ( + + )} + + + {label} + + {value ? ( + + {value} + + ) : null} + {showChevron && isAction ? ( + + ) : null} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + [ + styles.settingsRow, + isDisabled && styles.settingsRowDisabled, + pressed && !isDisabled && styles.pressed, + ]} + > + {content} + + ); +} + +function SettingsSection({ + children, + title, +}: { + children: ReactNode; + title: string; +}) { + return ( + + {title} + + {children} + + + ); +} + +type AccountAction = + | "appSettings" + | "organizationSettings" + | "refresh" + | "signOut"; + +export default function AccountScreen() { + const auth = useAuth(); + const appVersion = Constants.expoConfig?.version ?? "0.1.0"; + const [accountAction, setAccountAction] = useState( + null, + ); + const accountActionRef = useRef(null); + const accountActionHint = + accountAction === "refresh" + ? "Refresh is in progress" + : accountAction === "signOut" + ? "Sign out is in progress" + : accountAction !== null + ? "Settings are opening" + : null; + const accountActionDisabled = accountAction !== null; + + const runAccountAction = async ( + action: AccountAction, + operation: () => Promise, + ) => { + if (accountActionRef.current !== null) return; + accountActionRef.current = action; + setAccountAction(action); + try { + await operation(); + } finally { + accountActionRef.current = null; + setAccountAction(null); + } + }; + + const confirmSignOut = () => { + if (accountActionRef.current !== null) return; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + destructiveButtonIndex: 0, + message: "Remove this Cap session from your device?", + options: ["Sign out", "Cancel"], + title: "Sign out", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) { + void runAccountAction("signOut", auth.signOut); + } + }, + ); + return; + } + + Alert.alert("Sign out", "Remove this Cap session from your device?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Sign out", + style: "destructive", + onPress: () => { + void runAccountAction("signOut", auth.signOut); + }, + }, + ]); + }; + + if (auth.status === "loading") { + return ; + } + + if (auth.status === "signedOut") { + return ( + + + + ); + } + + return ( + + {auth.bootstrap ? ( + + + + {auth.bootstrap.user.imageUrl ? ( + + ) : ( + + {(auth.bootstrap.user.name ?? auth.bootstrap.user.email) + .slice(0, 1) + .toUpperCase()} + + )} + + + + {auth.bootstrap.user.name ?? "Cap user"} + + + {auth.bootstrap.user.email} + + + + + + ) : null} + + { + void runAccountAction("organizationSettings", () => + WebBrowser.openBrowserAsync( + new URL( + "/dashboard/settings/organization", + apiBaseUrl, + ).toString(), + ), + ); + }} + value={ + accountAction === "organizationSettings" ? "Opening..." : undefined + } + /> + + + { + void runAccountAction("refresh", auth.refresh); + }} + value={accountAction === "refresh" ? "Refreshing..." : undefined} + /> + + { + void runAccountAction("appSettings", Linking.openSettings); + }} + value={accountAction === "appSettings" ? "Opening..." : undefined} + /> + + + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + padding: 16, + gap: 16, + ...squircle, + }, + cardFallback: { + backgroundColor: colors.gray1, + }, + identityRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + avatar: { + width: 48, + height: 48, + borderRadius: radius.sm, + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.blue3, + ...squircle, + }, + avatarImage: { + width: "100%", + height: "100%", + }, + avatarText: { + fontFamily: fonts.medium, + fontSize: 18, + color: colors.blue11, + }, + identityText: { + flex: 1, + minWidth: 0, + }, + name: { + fontFamily: fonts.medium, + fontSize: 19, + color: colors.gray12, + }, + email: { + fontFamily: fonts.regular, + fontSize: 14, + color: colors.gray10, + marginTop: 2, + }, + settingsGroup: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + overflow: "hidden", + ...squircle, + }, + section: { + marginTop: 16, + gap: 8, + }, + sectionTitle: { + fontFamily: fonts.medium, + fontSize: 13, + lineHeight: 18, + color: colors.gray10, + paddingHorizontal: 4, + }, + settingsFallback: { + backgroundColor: colors.gray1, + }, + settingsRow: { + minHeight: 54, + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 14, + }, + settingsRowDisabled: { + backgroundColor: colors.gray2, + }, + pressed: { + backgroundColor: colors.gray3, + }, + settingsIcon: { + width: 30, + height: 30, + borderRadius: radius.sm, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray3, + ...squircle, + }, + settingsLabel: { + flex: 1, + fontFamily: fonts.medium, + fontSize: 16, + color: colors.gray12, + }, + settingsLabelDisabled: { + color: colors.gray9, + }, + settingsValue: { + fontFamily: fonts.regular, + fontSize: 15, + color: colors.gray10, + }, + settingsValueDisabled: { + color: colors.gray9, + }, + dangerLabel: { + color: colors.red9, + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.gray4, + marginLeft: 56, + }, +}); diff --git a/apps/mobile/src/screens/account-settings.test.tsx b/apps/mobile/src/screens/account-settings.test.tsx new file mode 100644 index 0000000000..ad331a8bc2 --- /dev/null +++ b/apps/mobile/src/screens/account-settings.test.tsx @@ -0,0 +1,377 @@ +import React, { type ReactElement, type ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AccountScreen from "../../app/(tabs)/account"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +const auth = vi.hoisted(() => ({ + value: { + status: "signedIn" as const, + bootstrap: { + activeOrganizationId: "org_123", + user: { + email: "richie@cap.so", + imageUrl: null, + name: "Richie", + }, + organizations: [ + { + id: "org_123", + iconUrl: null, + name: "Cap", + role: "owner", + }, + ], + rootFolders: [], + }, + refresh: vi.fn(() => Promise.resolve()), + setActiveOrganization: vi.fn(() => Promise.resolve()), + signOut: vi.fn(() => Promise.resolve()), + }, +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +}; + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActionSheetIOS: { + showActionSheetWithOptions: vi.fn(), + }, + ActivityIndicator: createHost("ActivityIndicator"), + Alert: { + alert: vi.fn(), + }, + Linking: { + openSettings: vi.fn(), + }, + Platform: { + OS: "ios", + }, + Pressable: createHost("Pressable"), + StyleSheet: { + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + View: createHost("View"), + }; +}); + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + version: "0.1.0", + }, + }, +})); + +vi.mock("expo-image", async () => { + const React = await import("react"); + return { + Image: (props: Record) => + React.createElement("Image", props), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +vi.mock("expo-web-browser", () => ({ + openBrowserAsync: vi.fn(), +})); + +vi.mock("@/auth/AuthContext", () => ({ + apiBaseUrl: "https://cap.so", + useAuth: () => auth.value, +})); + +vi.mock("@/auth/SignInPanel", async () => { + const React = await import("react"); + return { + SignInPanel: () => React.createElement("SignInPanel"), + }; +}); + +vi.mock("@/components/GlassSurface", async () => { + const React = await import("react"); + return { + GlassSurface: ({ children }: { children?: ReactNode }) => + React.createElement("GlassSurface", null, children), + }; +}); + +vi.mock("@/components/OrgSwitcher", async () => { + const React = await import("react"); + return { + OrgSwitcher: () => React.createElement("OrgSwitcher"), + }; +}); + +vi.mock("@/components/Screen", async () => { + const React = await import("react"); + return { + Screen: ({ + children, + subtitle, + title, + }: { + children?: ReactNode; + subtitle?: string | null; + title?: string; + }) => + React.createElement( + "Screen", + null, + title ? React.createElement("Text", null, title) : null, + subtitle ? React.createElement("Text", null, subtitle) : null, + children, + ), + }; +}); + +describe("AccountScreen", () => { + beforeEach(() => { + auth.value.refresh.mockReset(); + auth.value.refresh.mockResolvedValue(undefined); + auth.value.setActiveOrganization.mockReset(); + auth.value.setActiveOrganization.mockResolvedValue(undefined); + auth.value.signOut.mockReset(); + auth.value.signOut.mockResolvedValue(undefined); + }); + + it("opens organization settings in the native browser sheet", async () => { + const renderer = await renderComponent(React.createElement(AccountScreen)); + const text = getTextNodes(renderer.toJSON()); + const [organizationSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization Settings", + }); + if (!organizationSettings) { + throw new Error("Organization Settings row was not rendered"); + } + + expect(text).toContain("Account"); + expect(text).toContain("Organization Settings"); + expect(organizationSettings.props.accessibilityHint).toBe( + "Opens organization settings in a browser sheet", + ); + + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + const openDeferred = + createDeferred>>(); + openBrowserAsync.mockClear(); + openBrowserAsync.mockReturnValueOnce(openDeferred.promise); + + await act(async () => { + organizationSettings.props.onPress(); + await Promise.resolve(); + }); + + expect(openBrowserAsync).toHaveBeenCalledWith( + "https://cap.so/dashboard/settings/organization", + ); + const [openingOrganizationSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization Settings", + }); + expect(openingOrganizationSettings?.props.accessibilityValue).toEqual({ + text: "Opening organization settings", + }); + + await act(async () => { + openDeferred.resolve({ + type: "dismiss", + } as Awaited>); + await openDeferred.promise; + }); + }); + + it("marks app settings as opening with a native value", async () => { + const renderer = await renderComponent(React.createElement(AccountScreen)); + const [appSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "App Settings", + }); + if (!appSettings) throw new Error("App Settings row was not rendered"); + + const { Linking } = await import("react-native"); + const openSettings = vi.mocked(Linking.openSettings); + const openDeferred = createDeferred(); + openSettings.mockClear(); + openSettings.mockReturnValueOnce(openDeferred.promise); + + await act(async () => { + appSettings.props.onPress(); + await Promise.resolve(); + }); + + const [openingAppSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "App Settings", + }); + expect(openingAppSettings?.props.accessibilityValue).toEqual({ + text: "Opening iOS app settings", + }); + + await act(async () => { + openDeferred.resolve(); + await openDeferred.promise; + }); + }); + + it("locks account settings rows while refresh is in progress", async () => { + const refreshDeferred = createDeferred(); + auth.value.refresh.mockReturnValueOnce(refreshDeferred.promise); + const renderer = await renderComponent(React.createElement(AccountScreen)); + const [refreshRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Refresh", + }); + if (!refreshRow) throw new Error("Refresh row was not rendered"); + + await act(async () => { + refreshRow.props.onPress(); + await Promise.resolve(); + }); + + const [loadingRefreshRow] = renderer.root.findAllByProps({ + accessibilityLabel: "Refresh", + }); + const [organizationSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "Organization Settings", + }); + const [appSettings] = renderer.root.findAllByProps({ + accessibilityLabel: "App Settings", + }); + const [signOut] = renderer.root.findAllByProps({ + accessibilityLabel: "Sign out", + }); + if ( + !loadingRefreshRow || + !organizationSettings || + !appSettings || + !signOut + ) { + throw new Error("Account action rows were not rendered"); + } + + expect(loadingRefreshRow.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loadingRefreshRow.props.accessibilityHint).toBe( + "Refresh is in progress", + ); + expect(loadingRefreshRow.props.accessibilityValue).toEqual({ + text: "Refreshing account data", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Refreshing..."); + for (const row of [organizationSettings, appSettings, signOut]) { + expect(row.props.disabled).toBe(true); + expect(row.props.accessibilityState).toEqual({ + busy: false, + disabled: true, + }); + expect(row.props.accessibilityHint).toBe("Refresh is in progress"); + } + + await act(async () => { + refreshDeferred.resolve(); + await refreshDeferred.promise; + }); + }); + + it("shows sign-out as busy after confirmation", async () => { + const signOutDeferred = createDeferred(); + auth.value.signOut.mockReturnValueOnce(signOutDeferred.promise); + const renderer = await renderComponent(React.createElement(AccountScreen)); + const [signOut] = renderer.root.findAllByProps({ + accessibilityLabel: "Sign out", + }); + if (!signOut) throw new Error("Sign out row was not rendered"); + + const { ActionSheetIOS } = await import("react-native"); + const showActionSheetWithOptions = vi.mocked( + ActionSheetIOS.showActionSheetWithOptions, + ); + showActionSheetWithOptions.mockClear(); + + await act(async () => { + signOut.props.onPress(); + }); + + const [, callback] = showActionSheetWithOptions.mock.calls[0] ?? []; + if (!callback) + throw new Error("Sign-out confirmation callback was not set"); + + await act(async () => { + callback(0); + await Promise.resolve(); + }); + + const [loadingSignOut] = renderer.root.findAllByProps({ + accessibilityLabel: "Sign out", + }); + if (!loadingSignOut) throw new Error("Sign out row was not rendered"); + expect(loadingSignOut.props.accessibilityState).toEqual({ + busy: true, + disabled: true, + }); + expect(loadingSignOut.props.accessibilityHint).toBe( + "Sign out is in progress", + ); + expect(loadingSignOut.props.accessibilityValue).toEqual({ + text: "Signing out of Cap", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Signing out..."); + + await act(async () => { + signOutDeferred.resolve(); + await signOutDeferred.promise; + }); + }); +}); From 4bcb6230e3f676f95aded3a4e336a03d89c1a4b1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 17/20] feat(mobile): add cap detail screen --- apps/mobile/app/caps/[id].tsx | 1105 +++++++++++++++++++ apps/mobile/src/screens/cap-detail.test.tsx | 671 +++++++++++ 2 files changed, 1776 insertions(+) create mode 100644 apps/mobile/app/caps/[id].tsx create mode 100644 apps/mobile/src/screens/cap-detail.test.tsx diff --git a/apps/mobile/app/caps/[id].tsx b/apps/mobile/app/caps/[id].tsx new file mode 100644 index 0000000000..4fb96c8090 --- /dev/null +++ b/apps/mobile/app/caps/[id].tsx @@ -0,0 +1,1105 @@ +import * as Clipboard from "expo-clipboard"; +import { router, Stack, useLocalSearchParams } from "expo-router"; +import { type SFSymbol, SymbolView } from "expo-symbols"; +import { useVideoPlayer, VideoView } from "expo-video"; +import * as WebBrowser from "expo-web-browser"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActionSheetIOS, + Alert, + KeyboardAvoidingView, + Linking, + Platform, + Pressable, + Share, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import type { MobileCapDetail, MobilePlaybackResponse } from "@/api/mobile"; +import { apiBaseUrl, useAuth } from "@/auth/AuthContext"; +import { SignInPanel } from "@/auth/SignInPanel"; +import { CapSettingsSheet } from "@/caps/CapSettingsSheet"; +import { showCapPasswordActions } from "@/caps/passwordActions"; +import { + PhotosPermissionDeniedError, + saveCapVideoToPhotos, +} from "@/caps/saveCapVideo"; +import { showCapTitleActions } from "@/caps/titleActions"; +import { ActionButton } from "@/components/ActionButton"; +import { GlassSurface } from "@/components/GlassSurface"; +import { Screen } from "@/components/Screen"; +import { colors, fonts, radius, squircle } from "@/theme"; +import { formatRelativeDate } from "@/utils/format"; + +const showPhotosSettingsAlert = () => { + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + message: "Allow Cap to save videos to Photos from Settings.", + options: ["Open Settings", "Cancel"], + title: "Photos access needed", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) void Linking.openSettings(); + }, + ); + return; + } + + Alert.alert( + "Photos access needed", + "Allow Cap to save videos to Photos from Settings.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => { + void Linking.openSettings(); + }, + }, + ], + ); +}; + +const getCapDetailErrorMessage = (error: unknown) => + error instanceof Error ? error.message : "Unable to load this Cap"; + +type CapDetailOperation = "comment" | "save" | "visibility"; + +type AnalyticsMetricProps = { + symbol: SFSymbol; + value: number; +}; + +function AnalyticsMetric({ symbol, value }: AnalyticsMetricProps) { + return ( + + + {value} + + ); +} + +export default function CapDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const auth = useAuth(); + const [detail, setDetail] = useState(null); + const [playback, setPlayback] = useState(null); + const [comment, setComment] = useState(""); + const [loading, setLoading] = useState(true); + const [activeOperation, setActiveOperation] = + useState(null); + const [loadError, setLoadError] = useState(null); + const [copied, setCopied] = useState(false); + const [saved, setSaved] = useState(false); + const [settingsVisible, setSettingsVisible] = useState(false); + const player = useVideoPlayer(null); + + const load = useCallback(async () => { + if (auth.status !== "signedIn" || typeof id !== "string") return; + setLoading(true); + setLoadError(null); + try { + const [nextDetail, nextPlayback] = await Promise.all([ + auth.client.getCap(id), + auth.client.getPlayback(id), + ]); + setDetail(nextDetail); + setPlayback(nextPlayback); + } catch (error) { + setDetail(null); + setPlayback(null); + setLoadError(getCapDetailErrorMessage(error)); + } finally { + setLoading(false); + } + }, [auth, id]); + + useEffect(() => { + load().catch(() => {}); + }, [load]); + + useEffect(() => { + if (!playback?.url) return; + player.replace(playback.url); + }, [playback?.url, player]); + + useEffect(() => { + if (!copied) return; + const timeout = setTimeout(() => setCopied(false), 1600); + return () => clearTimeout(timeout); + }, [copied]); + + useEffect(() => { + if (!saved) return; + const timeout = setTimeout(() => setSaved(false), 1600); + return () => clearTimeout(timeout); + }, [saved]); + + const textComments = useMemo( + () => detail?.comments.filter((item) => item.type === "text") ?? [], + [detail], + ); + const reactions = useMemo( + () => detail?.comments.filter((item) => item.type === "emoji") ?? [], + [detail], + ); + const isActionInProgress = activeOperation !== null; + const isPostingComment = activeOperation === "comment"; + const isSavingVideo = activeOperation === "save"; + const isUpdatingVisibility = activeOperation === "visibility"; + const actionInProgressHint = "Current Cap action is in progress"; + const saveVideoLabel = saved ? "Saved" : "Save video"; + const saveVideoAccessibilityText = + isSavingVideo && detail + ? `Saving video for ${detail.cap.title}` + : saved && detail + ? `Saved video for ${detail.cap.title}` + : undefined; + const saveVideoAccessibilityLabel = saved + ? saveVideoAccessibilityText + : undefined; + const saveVideoAccessibilityValue = + isSavingVideo && saveVideoAccessibilityText + ? { text: saveVideoAccessibilityText } + : undefined; + const saveVideoHint = isSavingVideo + ? "Save is in progress" + : isActionInProgress + ? actionInProgressHint + : "Saves this video to Photos"; + const sharingStatusHint = isUpdatingVisibility + ? "Sharing update is in progress" + : isActionInProgress + ? actionInProgressHint + : "Opens sharing settings"; + const sharingStatusLabel = detail?.cap.public ? "Shared" : "Not shared"; + const sharingStatusAccessibilityValue = + isUpdatingVisibility && detail + ? `Updating sharing for ${detail.cap.title}` + : undefined; + const commentHint = isPostingComment + ? "Comment is being sent" + : isActionInProgress + ? actionInProgressHint + : "Add a comment to this Cap"; + const sendCommentHint = isPostingComment + ? "Comment is being sent" + : isActionInProgress + ? actionInProgressHint + : comment.trim().length > 0 + ? "Adds this comment" + : "Enter a comment before sending"; + const sendCommentLabel = isPostingComment ? "Sending..." : "Send"; + const sendCommentAccessibilityLabel = + isPostingComment && detail + ? `Sending comment on ${detail.cap.title}` + : "Send comment"; + const canSendComment = comment.trim().length > 0 && !isActionInProgress; + + const createComment = async () => { + const trimmed = comment.trim(); + if (!trimmed || !detail || isActionInProgress) return; + setActiveOperation("comment"); + try { + const created = await auth.client.createComment(detail.cap.id, { + content: trimmed, + timestamp: null, + }); + setDetail({ + ...detail, + comments: [...detail.comments, created], + cap: { + ...detail.cap, + commentCount: detail.cap.commentCount + 1, + }, + }); + setComment(""); + } catch (error) { + Alert.alert( + "Comment failed", + error instanceof Error ? error.message : "Unable to add that comment.", + ); + } finally { + setActiveOperation(null); + } + }; + + const createReaction = async (emoji: string) => { + if (!detail) return; + try { + const created = await auth.client.createReaction(detail.cap.id, { + content: emoji, + timestamp: null, + }); + setDetail({ + ...detail, + comments: [...detail.comments, created], + cap: { + ...detail.cap, + reactionCount: detail.cap.reactionCount + 1, + }, + }); + } catch (error) { + Alert.alert( + "Reaction failed", + error instanceof Error ? error.message : "Unable to add that reaction.", + ); + } + }; + + const copyLink = async () => { + if (!detail) return; + try { + await Clipboard.setStringAsync(detail.shareUrl); + setCopied(true); + } catch (error) { + Alert.alert( + "Copy failed", + error instanceof Error ? error.message : "Unable to copy this link.", + ); + } + }; + + const shareLink = async () => { + if (!detail) return; + await Share.share({ url: detail.shareUrl, message: detail.shareUrl }); + }; + + const updateVisibility = async (isPublic: boolean) => { + if (!detail || isActionInProgress) return; + setActiveOperation("visibility"); + try { + const cap = await auth.client.updateCapSharing(detail.cap.id, { + public: isPublic, + }); + setDetail((current) => (current ? { ...current, cap } : current)); + await auth.refresh(); + } catch (error) { + Alert.alert( + "Sharing update failed", + error instanceof Error + ? error.message + : "Unable to update sharing for this Cap.", + ); + } finally { + setActiveOperation(null); + } + }; + + const showPasswordActions = () => { + if (!detail || auth.status !== "signedIn") return; + showCapPasswordActions({ + cap: detail.cap, + client: auth.client, + onUpdated: async (cap) => { + setDetail((current) => (current ? { ...current, cap } : current)); + await auth.refresh(); + }, + }); + }; + + const showTitleActions = () => { + if (!detail || auth.status !== "signedIn") return; + showCapTitleActions({ + cap: detail.cap, + client: auth.client, + onUpdated: async (cap) => { + setDetail((current) => (current ? { ...current, cap } : current)); + await auth.refresh(); + }, + }); + }; + + const downloadVideo = async () => { + if (!detail || isActionInProgress) return; + setActiveOperation("save"); + try { + await saveCapVideoToPhotos(auth.client, detail.cap.id); + setSaved(true); + } catch (error) { + if (error instanceof PhotosPermissionDeniedError) { + showPhotosSettingsAlert(); + return; + } + Alert.alert( + "Save failed", + error instanceof Error ? error.message : "Unable to save this video.", + ); + } finally { + setActiveOperation(null); + } + }; + + const deleteCap = () => { + if (!detail || isActionInProgress) return; + const confirmDelete = () => { + void (async () => { + setSettingsVisible(false); + await auth.client.deleteCap(detail.cap.id); + await auth.refresh(); + router.back(); + })(); + }; + + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + cancelButtonIndex: 1, + destructiveButtonIndex: 0, + message: "This Cap will be removed from your library.", + options: ["Delete Cap", "Cancel"], + title: "Delete Cap", + tintColor: colors.blue11, + userInterfaceStyle: "light", + }, + (index) => { + if (index === 0) confirmDelete(); + }, + ); + return; + } + + Alert.alert("Delete Cap", "This Cap will be removed from your library.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: confirmDelete, + }, + ]); + }; + + const showMoreActions = () => { + setSettingsVisible(true); + }; + + const viewAnalytics = () => { + if (!detail || isActionInProgress) return; + const url = new URL("/dashboard/analytics", apiBaseUrl); + url.searchParams.set("capId", detail.cap.id); + void WebBrowser.openBrowserAsync(url.toString()); + }; + + if (auth.status === "signedOut") { + return ( + + + + ); + } + + return ( + + + detail ? ( + [ + styles.headerAction, + pressed && !isActionInProgress + ? styles.headerActionPressed + : null, + isActionInProgress ? styles.headerActionDisabled : null, + ]} + > + + + ) : null, + title: detail?.cap.title ?? "Cap", + }} + /> + + {loadError ? ( + + + Unable to load Cap + {loadError} + { + void load(); + }} + symbol="arrow.clockwise" + style={styles.retryButton} + /> + + ) : detail ? ( + <> + + {playback?.url ? ( + + ) : ( + + Processing video + + )} + + + {detail.cap.title} + + {formatRelativeDate(detail.cap.createdAt)} ·{" "} + {detail.cap.ownerName} + + + setSettingsVisible(true)} + style={({ pressed }) => [ + styles.shareStatusButton, + pressed && !isActionInProgress + ? styles.shareStatusButtonPressed + : null, + isActionInProgress + ? styles.shareStatusButtonDisabled + : null, + ]} + > + + + {sharingStatusLabel} + + + + {detail.cap.protected ? ( + + + + Password protected + + + ) : null} + + + + + + + + [ + styles.analyticsPanel, + pressed && styles.analyticsPanelPressed, + ]} + > + + + + + + View analytics + + {detail.summary ? ( + + Summary + {detail.summary} + + ) : null} + {detail.chapters.length > 0 ? ( + + Chapters + {detail.chapters.map((chapter) => ( + + + {Math.floor(chapter.start / 60)}: + {Math.floor(chapter.start % 60) + .toString() + .padStart(2, "0")} + + + {chapter.title} + + + ))} + + ) : null} + + + Reactions + {reactions.length} + + + {["👍", "👏", "🔥", "💙"].map((emoji) => ( + createReaction(emoji)} + style={styles.reactionButton} + > + {emoji} + + ))} + + + + + Comments + {textComments.length} + + + { + void createComment(); + }} + placeholder="Add a comment" + placeholderTextColor={colors.gray9} + returnKeyType="send" + selectionColor={colors.blue11} + style={[ + styles.commentInput, + isActionInProgress ? styles.commentInputDisabled : null, + ]} + submitBehavior="blurAndSubmit" + value={comment} + multiline + /> + + + {textComments.map((item) => ( + + + + + + + {item.author.name ?? "Cap user"} + + {item.content} + + + ))} + + + ) : null} + + setSettingsVisible(false)} + onCopyLink={() => { + void copyLink(); + }} + onDelete={() => deleteCap()} + onPassword={() => showPasswordActions()} + onRename={() => showTitleActions()} + onSaveVideo={() => { + void downloadVideo(); + }} + onShareLink={() => { + void shareLink(); + }} + onViewAnalytics={() => viewAnalytics()} + onVisibilityChange={(_cap, isPublic) => { + void updateVisibility(isPublic); + }} + saveDisabled={isActionInProgress} + saveDisabledHint={saveVideoHint} + saveDisabledValue={isSavingVideo ? undefined : "Unavailable"} + saveDisabledAccessibilityValue={ + isSavingVideo ? saveVideoAccessibilityText : undefined + } + visibilityDisabled={isActionInProgress} + visibilityDisabledHint={sharingStatusHint} + visibilityDisabledAccessibilityValue={sharingStatusAccessibilityValue} + /> + + ); +} + +const styles = StyleSheet.create({ + keyboard: { + flex: 1, + }, + headerAction: { + width: 36, + height: 36, + borderRadius: radius.full, + alignItems: "center", + justifyContent: "center", + }, + headerActionPressed: { + backgroundColor: colors.gray3, + }, + headerActionDisabled: { + opacity: 0.55, + }, + videoFrame: { + width: "100%", + aspectRatio: 16 / 9, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + overflow: "hidden", + backgroundColor: colors.black, + marginBottom: 14, + ...squircle, + }, + video: { + width: "100%", + height: "100%", + }, + videoPlaceholder: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + placeholderText: { + fontFamily: fonts.medium, + color: colors.gray10, + }, + titleBlock: { + gap: 4, + marginBottom: 14, + }, + title: { + fontFamily: fonts.medium, + fontSize: 24, + lineHeight: 30, + color: colors.gray12, + }, + meta: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: 8, + marginTop: 4, + }, + shareStatusButton: { + minHeight: 30, + maxWidth: "100%", + flexDirection: "row", + alignItems: "center", + gap: 6, + borderRadius: radius.full, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray4, + backgroundColor: colors.gray1, + paddingHorizontal: 11, + ...squircle, + }, + shareStatusButtonPressed: { + backgroundColor: colors.gray3, + borderColor: colors.gray5, + }, + shareStatusButtonDisabled: { + backgroundColor: colors.gray2, + borderColor: colors.gray3, + }, + shareStatusText: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 19, + color: colors.gray10, + }, + shareStatusTextDisabled: { + color: colors.gray9, + }, + passwordPill: { + minHeight: 30, + maxWidth: "100%", + flexDirection: "row", + alignItems: "center", + gap: 6, + borderRadius: radius.full, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray4, + backgroundColor: colors.gray1, + paddingHorizontal: 11, + ...squircle, + }, + passwordPillText: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 19, + color: colors.gray10, + }, + actions: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginBottom: 18, + }, + actionButton: { + flexBasis: 112, + flexGrow: 1, + }, + analyticsPanel: { + minHeight: 42, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + backgroundColor: colors.gray1, + paddingHorizontal: 14, + paddingVertical: 10, + marginBottom: 20, + ...squircle, + }, + analyticsPanelPressed: { + backgroundColor: colors.gray2, + borderColor: colors.blue10, + }, + analyticsMetrics: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: 16, + flexShrink: 1, + }, + metric: { + flexDirection: "row", + alignItems: "center", + gap: 7, + }, + metricText: { + fontFamily: fonts.regular, + fontSize: 14, + color: colors.gray12, + }, + analyticsLink: { + fontFamily: fonts.regular, + fontSize: 12, + lineHeight: 17, + color: colors.blue11, + }, + errorCard: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + backgroundColor: colors.gray1, + alignItems: "center", + gap: 10, + paddingHorizontal: 18, + paddingVertical: 24, + ...squircle, + }, + errorTitle: { + fontFamily: fonts.medium, + fontSize: 19, + color: colors.gray12, + textAlign: "center", + }, + errorBody: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray10, + textAlign: "center", + }, + retryButton: { + marginTop: 4, + minWidth: 150, + }, + section: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + gap: 10, + padding: 16, + marginBottom: 20, + ...squircle, + }, + sectionFallback: { + backgroundColor: colors.gray1, + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + sectionTitle: { + fontFamily: fonts.medium, + fontSize: 18, + lineHeight: 23, + color: colors.gray12, + }, + countText: { + fontFamily: fonts.medium, + fontSize: 14, + color: colors.gray10, + }, + bodyText: { + fontFamily: fonts.regular, + fontSize: 15, + lineHeight: 23, + color: colors.gray11, + }, + chapter: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: 10, + borderRadius: radius.sm, + backgroundColor: colors.gray2, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + padding: 10, + ...squircle, + }, + chapterTime: { + width: 44, + fontFamily: fonts.medium, + fontSize: 13, + color: colors.blue11, + }, + chapterTitle: { + flex: 1, + fontFamily: fonts.medium, + fontSize: 14, + color: colors.gray12, + }, + reactions: { + flexDirection: "row", + gap: 8, + }, + reactionButton: { + width: 52, + }, + commentInputRow: { + flexDirection: "row", + alignItems: "flex-end", + gap: 8, + }, + commentInput: { + flex: 1, + minHeight: 46, + maxHeight: 120, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray4, + backgroundColor: colors.gray2, + paddingHorizontal: 12, + paddingVertical: 10, + fontFamily: fonts.regular, + fontSize: 15, + color: colors.gray12, + ...squircle, + }, + commentInputDisabled: { + backgroundColor: colors.gray3, + color: colors.gray10, + }, + sendButton: { + width: 92, + }, + comment: { + flexDirection: "row", + gap: 10, + backgroundColor: colors.gray2, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.gray3, + padding: 12, + ...squircle, + }, + commentIcon: { + width: 30, + height: 30, + borderRadius: radius.sm, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.blue3, + ...squircle, + }, + commentBody: { + flex: 1, + minWidth: 0, + gap: 3, + }, + commentAuthor: { + fontFamily: fonts.medium, + fontSize: 14, + color: colors.gray12, + }, + commentText: { + fontFamily: fonts.regular, + fontSize: 14, + lineHeight: 20, + color: colors.gray11, + }, +}); diff --git a/apps/mobile/src/screens/cap-detail.test.tsx b/apps/mobile/src/screens/cap-detail.test.tsx new file mode 100644 index 0000000000..c987b6fae8 --- /dev/null +++ b/apps/mobile/src/screens/cap-detail.test.tsx @@ -0,0 +1,671 @@ +import { Comment, User, Video } from "@cap/web-domain"; +import React, { type ReactElement, type ReactNode } from "react"; +import TestRenderer, { + act, + type ReactTestRenderer, + type ReactTestRendererJSON, +} from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MobileCapDetail, + MobileComment, + MobilePlaybackResponse, +} from "@/api/mobile"; +import CapDetailScreen from "../../app/caps/[id]"; + +type HostProps = { + children?: ReactNode; + [key: string]: unknown; +}; + +type JsonNode = ReactTestRendererJSON | ReactTestRendererJSON[] | string | null; + +type AuthStub = { + status: "signedIn"; + client: { + createComment: ReturnType; + createReaction: ReturnType; + deleteCap: ReturnType; + getCap: ReturnType; + getPlayback: ReturnType; + updateCapSharing: ReturnType; + }; + refresh: ReturnType; +}; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const detail: MobileCapDetail = { + cap: { + id: Video.VideoId.make("video_123"), + shareUrl: "https://cap.so/s/video_123", + title: "Launch review", + createdAt: "2026-05-18T10:00:00.000Z", + updatedAt: "2026-05-18T10:30:00.000Z", + ownerName: "Richie", + durationSeconds: 125, + thumbnailUrl: null, + folderId: null, + public: true, + protected: true, + viewCount: 17, + commentCount: 2, + reactionCount: 3, + upload: null, + }, + summary: "A short launch walkthrough.", + chapters: [], + transcriptionStatus: "COMPLETE", + comments: [], + shareUrl: "https://cap.so/s/video_123", +}; + +const playback: MobilePlaybackResponse = { + kind: "mp4", + transcriptUrl: null, + url: "https://cap.so/video.mp4", +}; + +const createdComment = (content: string): MobileComment => ({ + id: Comment.CommentId.make("comment_123"), + videoId: Video.VideoId.make("video_123"), + type: "text", + content, + timestamp: null, + parentCommentId: null, + createdAt: "2026-05-18T10:31:00.000Z", + updatedAt: "2026-05-18T10:31:00.000Z", + author: { + id: User.UserId.make("user_123"), + name: "Richie", + imageUrl: null, + }, +}); + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +}; + +const authState = vi.hoisted((): { value: AuthStub | null } => ({ + value: null, +})); + +const renderComponent = async ( + node: ReactElement, +): Promise => { + let renderer: ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create(node); + await Promise.resolve(); + await Promise.resolve(); + }); + return renderer as unknown as ReactTestRenderer; +}; + +const getTextNodes = (node: JsonNode): string[] => { + if (!node) return []; + if (typeof node === "string") return [node]; + if (Array.isArray(node)) return node.flatMap(getTextNodes); + return node.children?.flatMap(getTextNodes) ?? []; +}; + +const hasProp = (node: JsonNode, prop: string, value: unknown): boolean => { + if (!node || typeof node === "string") return false; + if (Array.isArray(node)) + return node.some((item) => hasProp(item, prop, value)); + if (node.props[prop] === value) return true; + return node.children?.some((child) => hasProp(child, prop, value)) ?? false; +}; + +const resolveStyle = ( + style: unknown, + pressed = false, +): Record => { + const resolved = + typeof style === "function" + ? (style as (state: { pressed: boolean }) => unknown)({ pressed }) + : style; + const styles = Array.isArray(resolved) ? resolved : [resolved]; + return Object.assign({}, ...styles.filter(Boolean)); +}; + +const createAuth = (): AuthStub => ({ + status: "signedIn", + client: { + createComment: vi.fn(), + createReaction: vi.fn(), + deleteCap: vi.fn(), + getCap: vi.fn(() => Promise.resolve(detail)), + getPlayback: vi.fn(() => Promise.resolve(playback)), + updateCapSharing: vi.fn(), + }, + refresh: vi.fn(() => Promise.resolve()), +}); + +vi.mock("react-native", async () => { + const React = await import("react"); + const createHost = + (name: string) => + ({ children, ...props }: HostProps) => + React.createElement(name, props, children); + + return { + ActionSheetIOS: { + showActionSheetWithOptions: vi.fn(), + }, + Alert: { + alert: vi.fn(), + }, + KeyboardAvoidingView: createHost("KeyboardAvoidingView"), + Linking: { + openSettings: vi.fn(), + }, + Platform: { + OS: "ios", + }, + Pressable: createHost("Pressable"), + Share: { + share: vi.fn(), + }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: "absolute", + right: 0, + top: 0, + }, + create: >(styles: T) => styles, + hairlineWidth: 1, + }, + Text: createHost("Text"), + TextInput: createHost("TextInput"), + View: createHost("View"), + }; +}); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: vi.fn(), +})); + +vi.mock("expo-router", async () => { + const React = await import("react"); + return { + router: { + back: vi.fn(), + }, + Stack: { + Screen: (props: HostProps) => + React.createElement("StackScreen", { + ...props, + testID: "stack-screen", + }), + }, + useLocalSearchParams: () => ({ id: "video_123" }), + }; +}); + +vi.mock("expo-symbols", async () => { + const React = await import("react"); + return { + SymbolView: (props: Record) => + React.createElement("SymbolView", props), + }; +}); + +vi.mock("expo-video", async () => { + const React = await import("react"); + return { + VideoView: (props: Record) => + React.createElement("VideoView", props), + useVideoPlayer: () => ({ + replace: vi.fn(), + }), + }; +}); + +vi.mock("expo-web-browser", () => ({ + openBrowserAsync: vi.fn(), +})); + +vi.mock("@/auth/AuthContext", () => ({ + apiBaseUrl: "https://cap.so", + useAuth: () => authState.value, +})); + +vi.mock("@/auth/SignInPanel", async () => { + const React = await import("react"); + return { + SignInPanel: () => React.createElement("SignInPanel"), + }; +}); + +vi.mock("@/caps/CapSettingsSheet", async () => { + const React = await import("react"); + return { + CapSettingsSheet: (props: Record) => + React.createElement("CapSettingsSheet", { + ...props, + testID: "cap-settings-sheet", + }), + }; +}); + +vi.mock("@/caps/passwordActions", () => ({ + showCapPasswordActions: vi.fn(), +})); + +vi.mock("@/caps/saveCapVideo", () => ({ + PhotosPermissionDeniedError: class PhotosPermissionDeniedError extends Error {}, + saveCapVideoToPhotos: vi.fn(), +})); + +vi.mock("@/caps/titleActions", () => ({ + showCapTitleActions: vi.fn(), +})); + +vi.mock("@/components/ActionButton", async () => { + const React = await import("react"); + return { + ActionButton: ({ + children, + label, + onPress, + ...props + }: { + children?: ReactNode; + label: string; + onPress?: () => void; + [key: string]: unknown; + }) => + React.createElement( + "ActionButton", + { + ...props, + accessibilityLabel: props.accessibilityLabel ?? label, + onPress, + }, + children ?? label, + ), + }; +}); + +vi.mock("@/components/GlassSurface", async () => { + const React = await import("react"); + return { + GlassSurface: ({ children }: { children?: ReactNode }) => + React.createElement("GlassSurface", null, children), + }; +}); + +vi.mock("@/components/Screen", async () => { + const React = await import("react"); + return { + Screen: ({ + children, + loading, + }: { + children?: ReactNode; + loading?: boolean; + }) => + React.createElement( + "Screen", + null, + loading ? React.createElement("Text", null, "Loading") : children, + ), + }; +}); + +describe("Cap detail screen", () => { + beforeEach(() => { + vi.clearAllMocks(); + authState.value = createAuth(); + }); + + it("announces Cap detail load errors with a retry action", async () => { + const auth = createAuth(); + auth.client.getCap = vi.fn(() => + Promise.reject(new Error("Network unavailable")), + ); + authState.value = auth; + + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(text).toContain("Unable to load Cap"); + expect(text).toContain("Network unavailable"); + expect(hasProp(tree, "accessibilityRole", "alert")).toBe(true); + expect(hasProp(tree, "accessibilityLiveRegion", "polite")).toBe(true); + expect( + hasProp( + tree, + "accessibilityLabel", + "Cap detail error: Network unavailable", + ), + ).toBe(true); + + const [retryButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Try again", + }); + if (!retryButton) + throw new Error("Cap detail retry action was not rendered"); + expect(retryButton.props.accessibilityHint).toBe("Reloads this Cap"); + + await act(async () => { + retryButton.props.onPress(); + await Promise.resolve(); + }); + + expect(auth.client.getCap).toHaveBeenCalledTimes(2); + }); + + it("shows web-matching sharing, analytics, and action labels", async () => { + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const tree = renderer.toJSON(); + const text = getTextNodes(tree); + + expect(text).toContain("Launch review"); + expect(text).toContain("Shared"); + expect(text).toContain("Password protected"); + expect(text).toContain("17"); + expect(text).toContain("2"); + expect(text).toContain("3"); + expect(text).toContain("Copy link"); + expect(text).toContain("Save video"); + expect(text).toContain("View analytics"); + expect(hasProp(tree, "accessibilityHint", "Copies this Cap link")).toBe( + true, + ); + expect( + hasProp(tree, "accessibilityHint", "Opens the native share sheet"), + ).toBe(true); + expect( + hasProp(tree, "accessibilityHint", "Saves this video to Photos"), + ).toBe(true); + expect( + hasProp(tree, "accessibilityLabel", "Change sharing for Launch review"), + ).toBe(true); + expect(hasProp(tree, "accessibilityHint", "Opens sharing settings")).toBe( + true, + ); + expect( + hasProp(tree, "accessibilityLabel", "View analytics for Launch review"), + ).toBe(true); + expect( + hasProp(tree, "accessibilityHint", "Opens analytics in a browser sheet"), + ).toBe(true); + }); + + it("uses native affordances for the header menu and comment composer", async () => { + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const stackScreen = renderer.root.findByProps({ + testID: "stack-screen", + }); + const headerRight = stackScreen.props.options + .headerRight as () => ReactNode; + let headerRenderer: ReactTestRenderer | null = null; + + await act(async () => { + headerRenderer = TestRenderer.create(headerRight() as ReactElement); + }); + + const headerAction = ( + headerRenderer as unknown as ReactTestRenderer + ).root.findByProps({ + accessibilityLabel: "More actions", + }); + expect(headerAction.props.accessibilityState).toEqual({ + disabled: false, + }); + expect(headerAction.props.accessibilityHint).toBe("Opens Cap settings"); + expect(headerAction.props.hitSlop).toBe(10); + expect(resolveStyle(headerAction.props.style, true)).toMatchObject({ + backgroundColor: "#f0f0f0", + }); + + const [commentInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Comment", + }); + if (!commentInput) throw new Error("Comment input was not rendered"); + + expect(commentInput.props.accessibilityState).toEqual({ + disabled: false, + }); + expect(commentInput.props.enablesReturnKeyAutomatically).toBe(true); + expect(commentInput.props.keyboardAppearance).toBe("light"); + expect(commentInput.props.returnKeyType).toBe("send"); + expect(commentInput.props.selectionColor).toBe("#0d74ce"); + expect(commentInput.props.submitBehavior).toBe("blurAndSubmit"); + + const [disabledSend] = renderer.root.findAllByProps({ + accessibilityLabel: "Send comment", + }); + expect(disabledSend?.props.disabled).toBe(true); + + await act(async () => { + commentInput.props.onChangeText("Ship it"); + }); + + const [enabledSend] = renderer.root.findAllByProps({ + accessibilityLabel: "Send comment", + }); + expect(enabledSend?.props.disabled).toBe(false); + }); + + it("opens native settings from the sharing status", async () => { + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const [shareStatus] = renderer.root.findAllByProps({ + accessibilityLabel: "Change sharing for Launch review", + }); + if (!shareStatus) throw new Error("Sharing status row was not rendered"); + + await act(async () => { + shareStatus.props.onPress(); + }); + + const [sheet] = renderer.root.findAllByProps({ + testID: "cap-settings-sheet", + }); + expect(sheet?.props.visible).toBe(true); + }); + + it("opens analytics in the native browser sheet", async () => { + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const [analytics] = renderer.root.findAllByProps({ + accessibilityLabel: "View analytics for Launch review", + }); + if (!analytics) throw new Error("Analytics row was not rendered"); + + const WebBrowser = await import("expo-web-browser"); + const openBrowserAsync = vi.mocked(WebBrowser.openBrowserAsync); + openBrowserAsync.mockClear(); + + await act(async () => { + analytics.props.onPress(); + }); + + expect(openBrowserAsync).toHaveBeenCalledWith( + "https://cap.so/dashboard/analytics?capId=video_123", + ); + }); + + it("shows a save-specific busy state without blocking sharing as saving", async () => { + const saveDeferred = createDeferred(); + const { saveCapVideoToPhotos } = await import("@/caps/saveCapVideo"); + vi.mocked(saveCapVideoToPhotos).mockReturnValueOnce(saveDeferred.promise); + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const [saveButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Save video", + }); + if (!saveButton) throw new Error("Save video button was not rendered"); + + await act(async () => { + saveButton.props.onPress(); + await Promise.resolve(); + }); + + const [savingButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Save video", + }); + if (!savingButton) throw new Error("Saving button was not rendered"); + expect(savingButton.props.loading).toBe(true); + expect(savingButton.props.accessibilityHint).toBe("Save is in progress"); + expect(savingButton.props.accessibilityValue).toEqual({ + text: "Saving video for Launch review", + }); + + const [sheet] = renderer.root.findAllByProps({ + testID: "cap-settings-sheet", + }); + expect(sheet?.props.saveDisabled).toBe(true); + expect(sheet?.props.saveDisabledHint).toBe("Save is in progress"); + expect(sheet?.props.saveDisabledValue).toBeUndefined(); + expect(sheet?.props.saveDisabledAccessibilityValue).toBe( + "Saving video for Launch review", + ); + expect(sheet?.props.visibilityDisabled).toBe(true); + expect(sheet?.props.visibilityDisabledHint).toBe( + "Current Cap action is in progress", + ); + expect(sheet?.props.visibilityDisabledValue).toBeUndefined(); + + await act(async () => { + saveDeferred.resolve("Launch review.mp4"); + await saveDeferred.promise; + }); + + expect(getTextNodes(renderer.toJSON())).toContain("Saved"); + }); + + it("keeps save idle while a comment is sending", async () => { + const commentDeferred = createDeferred(); + const auth = createAuth(); + auth.client.createComment.mockReturnValueOnce(commentDeferred.promise); + authState.value = auth; + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const [commentInput] = renderer.root.findAllByProps({ + accessibilityLabel: "Comment", + }); + if (!commentInput) throw new Error("Comment input was not rendered"); + + await act(async () => { + commentInput.props.onChangeText("Ship it"); + }); + + const [sendButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Send comment", + }); + if (!sendButton) throw new Error("Send button was not rendered"); + + await act(async () => { + sendButton.props.onPress(); + await Promise.resolve(); + }); + + const [sendingButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Sending comment on Launch review", + }); + const [saveButton] = renderer.root.findAllByProps({ + accessibilityLabel: "Save video", + }); + if (!sendingButton) throw new Error("Sending button was not rendered"); + if (!saveButton) throw new Error("Save video button was not rendered"); + + expect(sendingButton.props.loading).toBe(true); + expect(sendingButton.props.accessibilityHint).toBe("Comment is being sent"); + expect(saveButton.props.loading).toBe(false); + expect(saveButton.props.disabled).toBe(true); + expect(saveButton.props.accessibilityHint).toBe( + "Current Cap action is in progress", + ); + expect(getTextNodes(renderer.toJSON())).not.toContain("Saving..."); + + const [sheet] = renderer.root.findAllByProps({ + testID: "cap-settings-sheet", + }); + expect(sheet?.props.saveDisabledValue).toBe("Unavailable"); + expect(sheet?.props.visibilityDisabledValue).toBeUndefined(); + + await act(async () => { + commentDeferred.resolve(createdComment("Ship it")); + await commentDeferred.promise; + }); + + expect(getTextNodes(renderer.toJSON())).toContain("Ship it"); + }); + + it("marks the settings sheet sharing row as updating during visibility changes", async () => { + const sharingDeferred = createDeferred(); + const auth = createAuth(); + auth.client.updateCapSharing.mockReturnValueOnce(sharingDeferred.promise); + authState.value = auth; + const renderer = await renderComponent( + React.createElement(CapDetailScreen), + ); + const [sheet] = renderer.root.findAllByProps({ + testID: "cap-settings-sheet", + }); + if (!sheet) throw new Error("Cap settings sheet was not rendered"); + + await act(async () => { + sheet.props.onVisibilityChange(detail.cap, false); + await Promise.resolve(); + }); + + const [busySheet] = renderer.root.findAllByProps({ + testID: "cap-settings-sheet", + }); + const [sharingStatus] = renderer.root.findAllByProps({ + accessibilityLabel: "Change sharing for Launch review", + }); + expect(auth.client.updateCapSharing).toHaveBeenCalledWith("video_123", { + public: false, + }); + expect(sharingStatus?.props.accessibilityHint).toBe( + "Sharing update is in progress", + ); + expect(sharingStatus?.props.accessibilityState).toEqual({ + disabled: true, + }); + expect(sharingStatus?.props.accessibilityValue).toEqual({ + text: "Updating sharing for Launch review", + }); + expect(getTextNodes(renderer.toJSON())).toContain("Shared"); + expect(getTextNodes(renderer.toJSON())).not.toContain("Updating..."); + expect(busySheet?.props.visibilityDisabled).toBe(true); + expect(busySheet?.props.visibilityDisabledHint).toBe( + "Sharing update is in progress", + ); + expect(busySheet?.props.visibilityDisabledValue).toBeUndefined(); + expect(busySheet?.props.visibilityDisabledAccessibilityValue).toBe( + "Updating sharing for Launch review", + ); + expect(busySheet?.props.saveDisabledValue).toBe("Unavailable"); + + await act(async () => { + sharingDeferred.resolve({ ...detail.cap, public: false }); + await sharingDeferred.promise; + }); + }); +}); From 2289db89bbc1fda0e5a3a350ed0b2adbe11d7b14 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 18/20] chore: add dev:mobile script and react-native overrides --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 8ae22dd6d8..9af939bb31 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --env-mode=loose --ui tui", "dev:desktop": "pnpm run --filter=@cap/desktop dev", "dev:manual": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/storybook --no-cache --concurrency 1", + "dev:mobile": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && CAP_MOBILE_DISABLE_ASSOCIATED_DOMAINS=1 EXPO_PUBLIC_CAP_WEB_URL=${EXPO_PUBLIC_CAP_WEB_URL:-http://localhost:3000} dotenv -e .env -- turbo run dev --filter=@cap/web --filter=@cap/mobile --env-mode=loose --ui tui", "dev:web": "pnpm dev --filter=!@cap/desktop", "dev:windows": "start /b cmd /c \"pnpm run docker:up > nul\" && timeout /t 5 /nobreak > nul && dotenv -e .env -- turbo run dev --env-mode=loose --ui tui", "docker:clean": "turbo run docker:clean", @@ -59,6 +60,9 @@ "typescript": "^5.8.3" }, "pnpm": { + "overrides": { + "react-native-worklets": "0.7.4" + }, "peerDependencyRules": { "allowedVersions": { "next-auth>next": ">=16.0.0" From 52fd01c961cfe64786d312df90e08438ee768cd5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 19/20] chore: update lockfile for mobile workspace --- pnpm-lock.yaml | 6375 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 6037 insertions(+), 338 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1003cb70e..cd167e2fdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + react-native-worklets: 0.7.4 + importers: .: @@ -115,7 +118,7 @@ importers: version: 0.14.10(solid-js@1.9.6) '@solidjs/start': specifier: ^1.1.3 - version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) + version: 1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1) '@tanstack/solid-query': specifier: ^5.51.21 version: 5.75.4(solid-js@1.9.6) @@ -205,7 +208,7 @@ importers: version: 9.0.1 vinxi: specifier: ^0.5.6 - version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + version: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) webcodecs: specifier: ^0.1.0 version: 0.1.0 @@ -242,19 +245,19 @@ importers: version: 5.8.3 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + version: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) vite-plugin-top-level-await: specifier: ^1.4.4 - version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) vite-plugin-wasm: specifier: ^3.4.1 - version: 3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) vite-tsconfig-paths: specifier: ^5.0.1 - version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ~2.1.9 - version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0) + version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0) apps/discord-bot: dependencies: @@ -285,7 +288,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.6.4 - version: 0.6.16(@cloudflare/workers-types@4.20250507.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0)) + version: 0.6.16(@cloudflare/workers-types@4.20250507.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)) '@cloudflare/workers-types': specifier: ^4.20250214.0 version: 4.20250507.0 @@ -294,7 +297,7 @@ importers: version: 5.8.3 vitest: specifier: ~2.1.9 - version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0) + version: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0) wrangler: specifier: ^3.109.1 version: 3.114.8(@cloudflare/workers-types@4.20250507.0) @@ -321,6 +324,133 @@ importers: specifier: latest version: 1.3.14 + apps/mobile: + dependencies: + '@cap/web-domain': + specifier: workspace:* + version: link:../../packages/web-domain + '@expo/config-plugins': + specifier: ~55.0.9 + version: 55.0.9 + '@expo/metro-runtime': + specifier: ~55.0.11 + version: 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@shopify/flash-list': + specifier: 2.0.2 + version: 2.0.2(@babel/runtime@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + effect: + specifier: ^3.18.4 + version: 3.18.4 + expo: + specifier: ~55.0.24 + version: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-clipboard: + specifier: ~55.0.13 + version: 55.0.13(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-constants: + specifier: ~55.0.16 + version: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-dev-client: + specifier: ~55.0.34 + version: 55.0.34(expo@55.0.24) + expo-document-picker: + specifier: ~55.0.13 + version: 55.0.13(expo@55.0.24) + expo-file-system: + specifier: ~55.0.20 + version: 55.0.20(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-font: + specifier: ~55.0.7 + version: 55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-glass-effect: + specifier: ~55.0.11 + version: 55.0.11(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-image: + specifier: ~55.0.10 + version: 55.0.10(expo@55.0.24)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-image-picker: + specifier: ~55.0.20 + version: 55.0.20(expo@55.0.24) + expo-linking: + specifier: ~55.0.15 + version: 55.0.15(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-media-library: + specifier: ~55.0.17 + version: 55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-modules-core: + specifier: ~55.0.25 + version: 55.0.25(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-router: + specifier: ~55.0.14 + version: 55.0.14(eed5efedde241c317111b390e3f7dd2b) + expo-secure-store: + specifier: ~55.0.14 + version: 55.0.14(expo@55.0.24) + expo-sharing: + specifier: ~55.0.19 + version: 55.0.19(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-symbols: + specifier: ~55.0.8 + version: 55.0.8(expo-font@55.0.7)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-video: + specifier: ~55.0.17 + version: 55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-web-browser: + specifier: ~55.0.16 + version: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + react-native: + specifier: 0.83.6 + version: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-gesture-handler: + specifier: ~2.30.0 + version: 2.30.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: + specifier: 4.2.1 + version: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: + specifier: ~5.6.2 + version: 5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: + specifier: ~4.23.0 + version: 4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-svg: + specifier: 15.15.5 + version: 15.15.5(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: + specifier: ~0.21.0 + version: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-native-worklets: + specifier: 0.7.4 + version: 0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + devDependencies: + '@testing-library/react-native': + specifier: ^13.3.3 + version: 13.3.3(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-test-renderer': + specifier: ^19.1.0 + version: 19.1.0 + babel-preset-expo: + specifier: ~55.0.21 + version: 55.0.21(@babel/core@7.27.1)(@babel/runtime@7.27.1)(expo@55.0.24)(react-refresh@0.14.2) + react-test-renderer: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + apps/storybook: dependencies: '@cap/ui-solid': @@ -365,16 +495,16 @@ importers: version: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4)) storybook-solidjs-vite: specifier: ^1.0.0-beta.2 - version: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + version: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) typescript: specifier: ^5.8.3 version: 5.8.3 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + version: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) vite-plugin-solid: specifier: ^2.10.2 - version: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) apps/web: dependencies: @@ -581,7 +711,7 @@ importers: version: 2.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@workos-inc/node': specifier: ^7.34.0 - version: 7.50.0(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 7.50.0(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) aws-sdk: specifier: ^2.1530.0 version: 2.1692.0 @@ -629,7 +759,7 @@ importers: version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) geist: specifier: ^1.3.1 - version: 1.4.2(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.4.2(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) gif.js: specifier: 0.2.0 version: 0.2.0 @@ -668,10 +798,10 @@ importers: version: 12.20.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@6.10.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 4.24.11(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@6.10.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-mdx-remote: specifier: ^6.0.0 version: 6.0.0(@types/react@19.2.14)(acorn@8.15.0)(react@19.2.4) @@ -716,7 +846,7 @@ importers: version: 5.28.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) recharts: specifier: ^3.3.0 - version: 3.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) + version: 3.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.6)(react@19.2.4)(redux@5.0.1) rehype-pretty-code: specifier: ^0.14.1 version: 0.14.1(shiki@3.23.0) @@ -761,7 +891,7 @@ importers: version: 9.0.1 workflow: specifier: 4.2.0-beta.73 - version: 4.2.0-beta.73(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3(@swc/helpers@0.5.17))(chokidar@5.0.0))(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(magicast@0.3.5)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.8.3) + version: 4.2.0-beta.73(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3(@swc/helpers@0.5.17))(chokidar@5.0.0))(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(magicast@0.3.5)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.8.3) zod: specifier: ^3.25.76 version: 3.25.76 @@ -831,7 +961,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) apps/web-cluster: dependencies: @@ -879,10 +1009,10 @@ importers: version: 1.0.0-beta.42 tsdown: specifier: ^0.15.6 - version: 0.15.6(typescript@5.8.3) + version: 0.15.6(typescript@5.9.3) tsup: specifier: ^8.5.0 - version: 8.5.0(@swc/core@1.15.5(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.8.1) + version: 8.5.0(@swc/core@1.15.5(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.3)(typescript@5.9.3)(yaml@2.8.1) devDependencies: concurrently: specifier: ^9.2.1 @@ -895,13 +1025,13 @@ importers: dependencies: '@pulumi/github': specifier: ^6.7.0 - version: 6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3) '@pulumi/pulumi': specifier: ^3.201.0 - version: 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3) '@pulumiverse/vercel': specifier: ^1.14.3 - version: 1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + version: 1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3) zod: specifier: ^3 version: 3.25.76 @@ -914,13 +1044,13 @@ importers: dependencies: '@vitejs/plugin-react': specifier: ^4.0.3 - version: 4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + version: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^4.2.0 - version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 4.3.2(typescript@5.9.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) zod: specifier: ^3 version: 3.25.76 @@ -930,16 +1060,16 @@ importers: version: 20.17.43 '@typescript-eslint/eslint-plugin': specifier: ^5.59.6 - version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + version: 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^5.59.6 - version: 5.62.0(eslint@8.57.1)(typescript@5.8.3) + version: 5.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: specifier: ^8.41.0 version: 8.57.1 eslint-config-next: specifier: 13.3.0 - version: 13.3.0(eslint@8.57.1)(typescript@5.8.3) + version: 13.3.0(eslint@8.57.1)(typescript@5.9.3) eslint-config-prettier: specifier: ^8.8.0 version: 8.10.0(eslint@8.57.1) @@ -957,7 +1087,7 @@ importers: version: 4.6.2(eslint@8.57.1) eslint-plugin-tailwindcss: specifier: ^3.12.0 - version: 3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))) + version: 3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3))) eslint-utils: specifier: ^3.0.0 version: 3.0.0(eslint@8.57.1) @@ -1011,13 +1141,13 @@ importers: version: 5.1.5 next: specifier: 15.5.9 - version: 15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: specifier: ^4.24.5 - version: 4.24.11(next@15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.24.11(next@15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-email: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 4.0.16(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) resend: specifier: 4.6.0 version: 4.6.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1069,7 +1199,7 @@ importers: dependencies: '@t3-oss/env-nextjs': specifier: ^0.12.0 - version: 0.12.0(typescript@5.8.3)(valibot@1.0.0-rc.1(typescript@5.8.3))(zod@3.25.76) + version: 0.12.0(typescript@5.9.3)(valibot@1.0.0-rc.1(typescript@5.9.3))(zod@3.25.76) zod: specifier: ^3.25.76 version: 3.25.76 @@ -1191,7 +1321,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.0.3 - version: 4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) autoprefixer: specifier: ^10.4.16 version: 10.4.21(postcss@8.5.3) @@ -1233,10 +1363,10 @@ importers: version: 5.8.3 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + version: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^4.2.1 - version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) packages/ui-solid: dependencies: @@ -1251,7 +1381,7 @@ importers: version: 1.9.6 tailwindcss: specifier: ^3.4.10 - version: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) zod: specifier: ^3 version: 3.25.76 @@ -1264,10 +1394,10 @@ importers: version: 2.2.336 '@kobalte/tailwindcss': specifier: ^0.9.0 - version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) + version: 0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))) '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))) autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.3) @@ -1279,16 +1409,16 @@ importers: version: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4)) tailwind-scrollbar: specifier: ^3.1.0 - version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) + version: 3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))) tailwindcss-animate: specifier: ^1.0.6 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))) unplugin-auto-import: specifier: ^0.18.2 version: 0.18.6(rollup@4.40.2) unplugin-fonts: specifier: ^1.1.1 - version: 1.3.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + version: 1.3.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) unplugin-icons: specifier: ^0.19.2 version: 0.19.3 @@ -1406,7 +1536,7 @@ importers: version: 3.18.4 next: specifier: 15.5.9 - version: 15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -1815,40 +1945,73 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.27.2': resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.27.1': resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} engines: {node: '>=6.9.0'} - '@babel/generator@7.27.1': - resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.27.5': - resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} '@babel/helper-module-transforms@7.27.1': @@ -1857,10 +2020,40 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.27.1': resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -1869,10 +2062,18 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + '@babel/helpers@7.27.1': resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==} engines: {node: '>=6.9.0'} @@ -1896,18 +2097,335 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-export-default-from@7.27.1': + resolution: {integrity: sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.28.6': + resolution: {integrity: sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@7.27.1': resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.27.1': + resolution: {integrity: sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.4': + resolution: {integrity: sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1': + resolution: {integrity: sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.27.1': + resolution: {integrity: sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -1920,6 +2438,84 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.27.1': resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} engines: {node: '>=6.9.0'} @@ -1932,6 +2528,10 @@ packages: resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.1': resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} engines: {node: '>=6.9.0'} @@ -1944,6 +2544,10 @@ packages: resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.1': resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} @@ -1956,6 +2560,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -2339,15 +2947,16 @@ packages: '@effect/rpc': ^0.71.0 effect: ^3.18.1 + '@egjs/hammerjs@2.0.17': + resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} + engines: {node: '>=0.8.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} - '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -2360,15 +2969,9 @@ packages: '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} - '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -3618,6 +4221,174 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@expo-google-fonts/material-symbols@0.4.38': + resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} + + '@expo/cli@55.0.30': + resolution: {integrity: sha512-luWcCgompncWtCi1HqQfY32MVOuD0kUeARpr1Le1LeKVtZykjOwnz7YWXZo5zjISiD7L/gQnBNGVrRjvREsJqg==} + hasBin: true + peerDependencies: + expo: '*' + expo-router: '*' + react-native: '*' + peerDependenciesMeta: + expo-router: + optional: true + react-native: + optional: true + + '@expo/code-signing-certificates@0.0.6': + resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + + '@expo/config-plugins@55.0.9': + resolution: {integrity: sha512-jLfpxru8dTo7eU0cqeTWuQav7byyjb37eF/mbXl1/3eTBHBvFU1VGxpeKxanUdTQAAjqzH8KGgWb0fWcce+z1w==} + + '@expo/config-types@55.0.5': + resolution: {integrity: sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==} + + '@expo/config@55.0.17': + resolution: {integrity: sha512-Y3VaRg7Jllg3MhlUOTQqHm6/dttsqcjYlnS9enhAllZvPUpTHnRA4YPETtUZlxkdMJy6y3UZe986pd/KfJ6OTg==} + + '@expo/devcert@1.2.1': + resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} + + '@expo/devtools@55.0.3': + resolution: {integrity: sha512-KoIDgo0NoXeWLsIcOdZqtAG/1LlsM+JL0DA3bo0vCYaOYTBLXi/ZvRBqa20Ub8D2vKLNa+FgRQW0gRg04Ps1Pg==} + peerDependencies: + react: '*' + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + + '@expo/dom-webview@55.0.6': + resolution: {integrity: sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + '@expo/env@2.1.2': + resolution: {integrity: sha512-RJtGFfj/ygO/6zcVbV3cckHf4THcEkv5IZft1GjCB3dfT6axvzvIwXE9EiQqQYmGHcQ+ZrvC8xZcIhiHba0pYg==} + engines: {node: '>=20.12.0'} + + '@expo/fingerprint@0.16.7': + resolution: {integrity: sha512-BH8sicYOqZ1iBMwCVEGIz6uTTfylosjc49FoMmCYIzKOiYdiVehsfoYBwyfxwWIiya1VMhm1gv0cgOP8fxHpDw==} + hasBin: true + + '@expo/image-utils@0.8.14': + resolution: {integrity: sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==} + + '@expo/json-file@10.0.14': + resolution: {integrity: sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==} + + '@expo/local-build-cache-provider@55.0.13': + resolution: {integrity: sha512-Vg5BE10UL+0yg3BVtIeiSoeHU31Qe1m3UxhBPS478ACY1zzKuxZE30x2sym/B2OIWypjmPzXDRt8J9TOGFuFNw==} + + '@expo/log-box@55.0.12': + resolution: {integrity: sha512-f9ARS8J60cq3LLNdIqmUjYwyerBzVS5Ecp7KjIf3GOIPjW0571rkcwLz4/U18l/1DeSkSzIkYsNl2TC9oTdWaQ==} + peerDependencies: + '@expo/dom-webview': ^55.0.6 + expo: '*' + react: '*' + react-native: '*' + + '@expo/metro-config@55.0.21': + resolution: {integrity: sha512-pJ8G0uCxqA9KK+XCzXZF7ZI37rduD2l7Cun2e3rVAgB2yeOZagUD+VBvooU9QPiWx9e/7EbimH5/JP81JyhQlg==} + peerDependencies: + expo: '*' + peerDependenciesMeta: + expo: + optional: true + + '@expo/metro-runtime@55.0.11': + resolution: {integrity: sha512-4KKi/jGrIEXi2YGu0hYTVr0CEeRJy5SXbCrz9+KDZkuD3ROwKNpM1DBawni5rhPVovFnR323HBck9GaxhnfrRw==} + peerDependencies: + expo: '*' + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + + '@expo/metro@55.1.1': + resolution: {integrity: sha512-/wfXo5hTuAVpVLG/4hzlmD9NBGJkzkmBEMm/4VICajYRbj7y8OmqqPWbbymzHiBiHB6tI9BnsyXpQM6zVZEECg==} + + '@expo/osascript@2.4.3': + resolution: {integrity: sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==} + engines: {node: '>=12'} + + '@expo/package-manager@1.10.5': + resolution: {integrity: sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==} + + '@expo/plist@0.5.3': + resolution: {integrity: sha512-jz5oPcPDd3fygwVxwSwmO6wodTwm0Qa14NUyPy0ka7H8sFmCtNZUI2+DzVe/EXjOhq1FbEjrwl89gdlWYOnVjQ==} + + '@expo/prebuild-config@55.0.18': + resolution: {integrity: sha512-2oKXyy5pyM87DJqXW5Z+Sakle6rApFFtpPhWOiNsOdoh6rOAD+EqVgyrs2OEEic8CE0tTt27w3SRfSZe/PZrxg==} + peerDependencies: + expo: '*' + + '@expo/require-utils@55.0.5': + resolution: {integrity: sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==} + peerDependencies: + typescript: ^5.0.0 || ^5.0.0-0 + peerDependenciesMeta: + typescript: + optional: true + + '@expo/router-server@55.0.16': + resolution: {integrity: sha512-LvAdrm039nQBG+95+ff5Rc4CsBuoc/giDhjQrgxB9lKJqC/ZTq1xbwfEZFNq6yokX6fOCs/vlxdhmSkOjMIrvg==} + peerDependencies: + '@expo/metro-runtime': ^55.0.11 + expo: '*' + expo-constants: ^55.0.16 + expo-font: ^55.0.7 + expo-router: '*' + expo-server: ^55.0.9 + react: '*' + react-dom: '*' + react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 + peerDependenciesMeta: + '@expo/metro-runtime': + optional: true + expo-router: + optional: true + react-dom: + optional: true + react-server-dom-webpack: + optional: true + + '@expo/schema-utils@55.0.4': + resolution: {integrity: sha512-65IdeeE8dAZR3n3J5Eq7LYiQ8BFGeEYCWPBCzycvafL7PkskbCyIclTQarRwf/HXFoRvezKCjaLwy/8v9Prk6g==} + + '@expo/sdk-runtime-versions@1.0.0': + resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} + + '@expo/spawn-async@1.7.2': + resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} + engines: {node: '>=12'} + + '@expo/sudo-prompt@9.3.2': + resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + + '@expo/vector-icons@15.1.1': + resolution: {integrity: sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==} + peerDependencies: + expo-font: '>=14.0.4' + react: '*' + react-native: '*' + + '@expo/ws-tunnel@1.0.6': + resolution: {integrity: sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==} + + '@expo/xcpretty@4.4.4': + resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} + hasBin: true + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -4024,10 +4795,54 @@ packages: '@isaacs/string-locale-compare@1.1.0': resolution: {integrity: sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==} + '@isaacs/ttlcache@1.4.1': + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jest/create-cache-key-function@29.7.0': + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -5151,6 +5966,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.6': resolution: {integrity: sha512-2JMfHJf/eVnwq+2dewT3C0acmCWD3XiVA1Da+jTDqo342UlU13WvXtqHhG+yJw5JeQmu4ue2eMy6gcEArLBlcw==} peerDependencies: @@ -5493,6 +6311,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@1.0.0': resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==} peerDependencies: @@ -5525,6 +6356,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.9': resolution: {integrity: sha512-ZzrIFnMYHHCNqSNCsuN6l7wlewBEq0O0BCSBkabJMFXVO51LRUTq71gLP1UxFvmrXElqmPjA5VX7IqC9VpazAQ==} peerDependencies: @@ -5600,6 +6444,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.2.6': resolution: {integrity: sha512-zYb+9dc9tkoN2JjBDIIPLQtk3gGyz8FMKoqYTb8EMVQ5a5hBcdHPECrsZVI4NpPAUOixhkoqg7Hj5ry5USowfA==} peerDependencies: @@ -5859,6 +6716,149 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-native/assets-registry@0.83.6': + resolution: {integrity: sha512-iljb4ue1yWJ3EhySz7EjV6CzSVrI2uNtR8BI2jzP5+QS5E4Cl3fdIJRmVwDEx1pu8uE97PGEusGRHnoaZ9Q3jg==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.83.6': + resolution: {integrity: sha512-qfRXsHGeucT5c6mK+8Q7v4Ly3zmygfVmFlEtkiq7q07W1OTreld6nib4rJ/DBEeNiKBoBTuHjWliYGNuDjLFQA==} + engines: {node: '>= 20.19.4'} + + '@react-native/babel-plugin-codegen@0.85.3': + resolution: {integrity: sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/babel-preset@0.83.6': + resolution: {integrity: sha512-4/fXFDUvGOObETZq4+SUFkafld6OGgQWut5cQiqVghlhCB5z/p2lVhPgEUr/aTxTzeS3AmN+ztC+GpYPQ7tsTw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/babel-preset@0.85.3': + resolution: {integrity: sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.83.6': + resolution: {integrity: sha512-doB/Pq6Cf6IjF3wlQXTIiZOnsX9X8mEEk+CdGfyuCwZjWrf7IB8KaZEXXckJmfUcIwvJ9u/a72ZoTTCIoxAc9A==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.85.3': + resolution: {integrity: sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/community-cli-plugin@0.83.6': + resolution: {integrity: sha512-Mko6mywoHYJmpBnjwAC95vQWaUUh//71knFadH0BrhHDq2m7i/IrpLwcQsPAy8855ucXflBs5zQyGTpNbPBAaw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + + '@react-native/debugger-frontend@0.83.6': + resolution: {integrity: sha512-TyWXEpAjVundrc87fPWg91piOUg75+X9iutcfDe7cO3NrAEYCsl7Z09rKHuiAGkxfG9/rFD13dPsYIixUFkSFA==} + engines: {node: '>= 20.19.4'} + + '@react-native/debugger-shell@0.83.6': + resolution: {integrity: sha512-684TJMBCU0l0ZjJWzrnK0HH+ERaM9KLyxyArE1k7BrP+gVl4X9GO0Pi94RoInOxvW/nyV65sOU6Ip1F3ygS0cg==} + engines: {node: '>= 20.19.4'} + + '@react-native/dev-middleware@0.83.6': + resolution: {integrity: sha512-22xoddLTelpcVnF385SNH2hdP7X2av5pu7yRl/WnM5jBznbcl0+M9Ce94cj+WVeomsoUF/vlfuB0Ooy+RMlRiA==} + engines: {node: '>= 20.19.4'} + + '@react-native/gradle-plugin@0.83.6': + resolution: {integrity: sha512-5prXv7WWR1RgZ/kWGZP+mi7/y/IE2ymfOHIZO5Pv14tMOmRAcQSgSYogcRmOiWw5mJs2K0UFeMiQD49ZO9oCug==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.83.6': + resolution: {integrity: sha512-VSev0LV2i5X0ibduHBSLqKj0YU2F+waCgjl2uvaGHMGCSV1ZRKNFX/vJFqvLwjvdzLbkAZoFT1Rg7k7jDv44UA==} + engines: {node: '>= 20.19.4'} + + '@react-native/js-polyfills@0.85.3': + resolution: {integrity: sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/metro-babel-transformer@0.85.3': + resolution: {integrity: sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/metro-config@0.85.3': + resolution: {integrity: sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + '@react-native/normalize-colors@0.74.89': + resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} + + '@react-native/normalize-colors@0.83.6': + resolution: {integrity: sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw==} + + '@react-native/virtualized-lists@0.83.6': + resolution: {integrity: sha512-gNSFXeb4P7qHtauLvl+zESroULIyX6Ltpvau3dhwy/QmfanBv0KUcrIU/7aVXxtWcXgp+54oWJyu2LIrsZ9+LQ==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@react-navigation/bottom-tabs@7.16.1': + resolution: {integrity: sha512-wjFATJmbq0K8B96Ax0JcK2+Eu7syfYvQ5qUd/tgcv8JuCYLwKKqojJMAl31qdjpKqFG09pQ6TSdEDHOek60CAA==} + peerDependencies: + '@react-navigation/native': ^7.2.4 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/core@7.17.4': + resolution: {integrity: sha512-Rv9E2oNNQEkPGpmu9q+vJwGJRSQR6LBg5L+Yo1QHjtwGbHUbjkIKOdYymDZoZYgNzX2OD4rAIlfuzbDKa3cCeA==} + peerDependencies: + react: '>= 18.2.0' + + '@react-navigation/elements@2.9.18': + resolution: {integrity: sha512-mKEvDr6CkCVYZSb8W9WubNseihL+1c8M7ktZJCTCbMk8rQgdQfkdRNwpSUQKspdGpUHCb9cyzvaiuzl1NtjVgw==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.2.4 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + + '@react-navigation/native-stack@7.15.1': + resolution: {integrity: sha512-kNrJggwoB/onC0MpZIuZ6qaqeAziFchz+W9txBzhd6qbWmB1OkPVUnu6fWgc6BQc7MeMf59djVmqgX+6kJU1Ug==} + peerDependencies: + '@react-navigation/native': ^7.2.4 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/native@7.2.4': + resolution: {integrity: sha512-eWC2D3JjhYLId2fVTZhhCiUpWIaPhO9XyEb7Wq8ElmOHyIODlbOzgZ0rKia02OIsDKr9BzZl2sK1dL70yMxDaw==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + + '@react-navigation/routers@7.5.5': + resolution: {integrity: sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ==} + '@reduxjs/toolkit@2.10.1': resolution: {integrity: sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==} peerDependencies: @@ -6356,6 +7356,13 @@ packages: '@shinyoshiaki/jspack@0.0.6': resolution: {integrity: sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==} + '@shopify/flash-list@2.0.2': + resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@sigstore/bundle@2.3.2': resolution: {integrity: sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -6380,6 +7387,12 @@ packages: resolution: {integrity: sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g==} engines: {node: ^16.14.0 || >=18.0.0} + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -6396,6 +7409,12 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@smithy/abort-controller@4.0.2': resolution: {integrity: sha512-Sl/78VDtgqKxN2+1qduaVE140XF+Xg+TafkncspwM4jFP/LHr76ZHmIY/y3V1M0mMLNk+Je6IGbzxy23RSToMw==} engines: {node: '>=18.0.0'} @@ -7778,6 +8797,18 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react-native@13.3.3': + resolution: {integrity: sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==} + engines: {node: '>=18'} + peerDependencies: + jest: '>=29.0.0' + react: '>=18.2.0' + react-native: '>=0.71' + react-test-renderer: '>=18.2.0' + peerDependenciesMeta: + jest: + optional: true + '@testing-library/user-event@14.5.2': resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} engines: {node: '>=12', npm: '>=6'} @@ -7970,6 +9001,12 @@ packages: '@types/google-protobuf@3.15.12': resolution: {integrity: sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==} + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -7982,6 +9019,15 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -8070,6 +9116,9 @@ packages: '@types/react-responsive-masonry@2.6.0': resolution: {integrity: sha512-MF2ql1CjzOoL9fLWp6L3ABoyzBUP/YV71wyb3Fx+cViYNj7+tq3gDCllZHbLg1LQfGOQOEGbV2P7TOcUeGiR6w==} + '@types/react-test-renderer@19.1.0': + resolution: {integrity: sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==} + '@types/react-tooltip@4.2.4': resolution: {integrity: sha512-UzjzmgY/VH3Str6DcAGTLMA1mVVhGOyARNTANExrirtp+JgxhaIOVDxq4TIRmpSi4voLv+w4HA9CC5GvhhCA0A==} deprecated: This is a stub types definition. react-tooltip provides its own type definitions, so you do not need this installed. @@ -8098,6 +9147,9 @@ packages: '@types/shimmer@1.2.0': resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} @@ -8125,6 +9177,12 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -8724,6 +9782,14 @@ packages: resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==} engines: {node: ^14.14.0 || >=16.0.0} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -8849,6 +9915,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -8864,6 +9933,10 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -9006,6 +10079,9 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -9099,11 +10175,84 @@ packages: babel-dead-code-elimination@1.0.10: resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + babel-plugin-jsx-dom-expressions@0.39.8: resolution: {integrity: sha512-/MVOIIjonylDXnrWmG23ZX82m9mtKATsVHB7zYlPfDR9Vdd/NBE48if+wv27bSkBtyO7EPMUlcUc4J63QwuACQ==} peerDependencies: '@babel/core': ^7.20.12 + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + babel-plugin-react-native-web@0.21.2: + resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} + + babel-plugin-syntax-hermes-parser@0.32.0: + resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} + + babel-plugin-syntax-hermes-parser@0.32.1: + resolution: {integrity: sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==} + + babel-plugin-syntax-hermes-parser@0.33.3: + resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-expo@55.0.21: + resolution: {integrity: sha512-anXoUZBcxydLdVs2L+r3bWKGUvZv2FtgOl8xRJ12i/YfKICBpwTGZWSTiEYTqBByZ6GkA3mE9+3TW97X2ocFTQ==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^55.0.17 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + babel-preset-solid@1.9.6: resolution: {integrity: sha512-HXTK9f93QxoH8dYn1M2mJdOlWgMsR88Lg/ul6QCZGkNTktjTE5HAf93YxQumHoCudLEtZrU1cFCMFOVho6GqFg==} peerDependencies: @@ -9134,8 +10283,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.8.16: - resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==} + baseline-browser-mapping@2.10.30: + resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==} + engines: {node: '>=6.0.0'} hasBin: true before-after-hook@2.2.3: @@ -9151,6 +10301,10 @@ packages: bezier-easing@2.1.0: resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bin-links@4.0.4: resolution: {integrity: sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -9193,6 +10347,9 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -9203,6 +10360,17 @@ packages: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} + bplist-creator@0.1.0: + resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} + + bplist-parser@0.3.1: + resolution: {integrity: sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==} + engines: {node: '>= 5.10.0'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -9230,6 +10398,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -9337,6 +10513,14 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -9347,6 +10531,9 @@ packages: caniuse-lite@1.0.30001750: resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + canvas-confetti@1.9.3: resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} @@ -9448,10 +10635,25 @@ packages: '@chromatic-com/playwright': optional: true + chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromium-edge-launcher@0.2.0: + resolution: {integrity: sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==} + + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -9476,6 +10678,10 @@ packages: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -9568,6 +10774,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -9583,6 +10793,10 @@ packages: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -9607,6 +10821,14 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -9625,6 +10847,10 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -9675,6 +10901,9 @@ packages: cookies-next@4.3.0: resolution: {integrity: sha512-XxeCwLR30cWwRd94sa9X5lRCDLVujtx73tv+N0doQCFIDl83fuuYdxbu/WQUt9aSV7EJx7bkMvJldjvzuFqr4w==} + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js@3.42.0: resolution: {integrity: sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==} @@ -9731,6 +10960,20 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -9927,6 +11170,10 @@ packages: decode-named-character-reference@1.1.0: resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -10105,6 +11352,9 @@ packages: resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} engines: {node: '>=6'} + dnssd-advertise@1.1.4: + resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -10319,6 +11569,9 @@ packages: electron-to-chromium@1.5.234: resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==} + electron-to-chromium@1.5.357: + resolution: {integrity: sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -10507,6 +11760,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -10854,6 +12111,226 @@ packages: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} + expo-asset@55.0.17: + resolution: {integrity: sha512-pK9HHJuFqjE8kDUcbMFsZj3Cz8WdXpvZHZmYl7ouFQp59P83BvHln6VnqPDGlO+/4929G0Lm8ZUzbONuNRhi9w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-clipboard@55.0.13: + resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-constants@55.0.16: + resolution: {integrity: sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-client@55.0.34: + resolution: {integrity: sha512-IiQcIyzE/ixWtOa73XGf/7bsIN4DRnMvrmheCvCkqFIUv/mi+RLQt9D+xRRVbIwfnmjgDCjGxOLJVzFEcUbcIg==} + peerDependencies: + expo: '*' + + expo-dev-launcher@55.0.35: + resolution: {integrity: sha512-Cfdx4exreS9J7zLe9iE+ARItpse1ixjdXn+5W0ZdqCYdSrN+AabKtHmevXOYImBn+R1aXdA8UGkJ/W6OoCXjNQ==} + peerDependencies: + expo: '*' + + expo-dev-menu-interface@55.0.2: + resolution: {integrity: sha512-DomUNvGzY/xliwnMdbAYY780sCv19N7zIbifc0ClcoCzJZpNSCkvJ2qGIFRPyM/7DmqmlHGCKi8di7kYYLKNEg==} + peerDependencies: + expo: '*' + + expo-dev-menu@55.0.29: + resolution: {integrity: sha512-dzKE+2Ag8nHhTgSetjDVR+u4UvgaCfRdQrl6tJyFbeYHJ2CZVxhRsMfH4ULQxF5ry/bJeSxZ9dbQWizGnXP9mg==} + peerDependencies: + expo: '*' + + expo-document-picker@55.0.13: + resolution: {integrity: sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==} + peerDependencies: + expo: '*' + + expo-file-system@55.0.20: + resolution: {integrity: sha512-sBCHhNlCT3EiqCcE6xSbyvOLUAlKx7+p0qjo+c+UPyC/gMrXUdva99g25uptM+fEMwy2co25MUQQ0U0guQLOQA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@55.0.7: + resolution: {integrity: sha512-oH39Xb+3i6Y69b7YRP+P+5WLx7621t+ep/RAgLwJJYpTjs7CnSohUG+873rEtqsTAuQGi63ms7x9ZeHj1E9LYw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-glass-effect@55.0.11: + resolution: {integrity: sha512-wqq7GUOqSkfoFJzreZvBG0jzjsq5c582m3glhWSjcmIuByxXXWp6j6GY6hyFuYKzpOXhbuvusVxGCQi0yWnp3g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-image-loader@55.0.0: + resolution: {integrity: sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==} + peerDependencies: + expo: '*' + + expo-image-picker@55.0.20: + resolution: {integrity: sha512-lfWt/0rPWdKz8AdDEGmGHZIJSNlVc720Dlx5bfou10FU16ZV5wAbTU63nm2jkXd8hbXke4a/2Ha1dzxCVA+LQQ==} + peerDependencies: + expo: '*' + + expo-image@55.0.10: + resolution: {integrity: sha512-We+vq/Z8jy8zmGxcOP8vrhiWkkwyXFdSks8cSlPi0bpu6D0Ei6l9Nj2xHWCD+yoENh92aCEe1+QRujAwXbogGA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + + expo-json-utils@55.0.2: + resolution: {integrity: sha512-QJMOZOPOG7CTnKcrdVaiummn2va1MCO56z++eyWkDv3GBRODldM6MFMDf/jTREWthFc2Nxo6TuyWRrEV9S6n/Q==} + + expo-keep-awake@55.0.8: + resolution: {integrity: sha512-PfIpMfM+STOBwkR5XOE+yVtER86c44MD+W8QD8JxuO0sT9pF7Y1SJYakWlpvX8xsGA+bjKLxftm9403s9kQhKA==} + peerDependencies: + expo: '*' + react: '*' + + expo-linking@55.0.15: + resolution: {integrity: sha512-/RQh2vkNqV8Bim9Owm/evVqn2fqTvCDYHkpYPoSKbLAdydSGdHC2xZNw7Odl4wu1i1/3L4Xz//LKd3NsPWYWBQ==} + peerDependencies: + react: '*' + react-native: '*' + + expo-manifests@55.0.17: + resolution: {integrity: sha512-vKZvFivX3usVJKfBODKQcFHso0g38zlGbRGqGAppz+il0zKvG6umpJ47OZbzLod7iJpjd+ZDD2AGuOxacixonA==} + peerDependencies: + expo: '*' + + expo-media-library@55.0.17: + resolution: {integrity: sha512-x/8bdVZAjjB/yitlYZs87qXxxCpJdQBhJ0juXcGHPXCkijNz2sef6BmnbbK5FXg8jxf5nHkG0bIUQgo223hOvw==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-modules-autolinking@55.0.22: + resolution: {integrity: sha512-13x32V0HMHJDjND4K/gU2lQIZNxYn5S5rFzujqHmnXvOO6WGrVVELpk/0p5FmBfeuQ7GGFsATbhazQk+FeukUw==} + hasBin: true + + expo-modules-core@55.0.25: + resolution: {integrity: sha512-yXpfg7aHLbuqoXocK34Vua6Aey5SCyqLygAsXAMbul9P8vfBjLpaOPiTJ5cLVF7Drfq8ownqVJO6qpGEtZ6GOw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: 0.7.4 + peerDependenciesMeta: + react-native-worklets: + optional: true + + expo-router@55.0.14: + resolution: {integrity: sha512-rOn/wosp2hAPM+O2o41hnarbP5Zqv9UkHWa31KoSoiOme1tpmZd2yc93XtRAtzP0P5E5xzqq7a2rbEAarpP5XA==} + peerDependencies: + '@expo/log-box': 55.0.12 + '@expo/metro-runtime': ^55.0.11 + '@react-navigation/drawer': ^7.9.4 + '@testing-library/react-native': '>= 13.2.0' + expo: '*' + expo-constants: ^55.0.16 + expo-linking: ^55.0.15 + react: '*' + react-dom: '*' + react-native: '*' + react-native-gesture-handler: '*' + react-native-reanimated: '*' + react-native-safe-area-context: '>= 5.4.0' + react-native-screens: '*' + react-native-web: '*' + react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 + peerDependenciesMeta: + '@react-navigation/drawer': + optional: true + '@testing-library/react-native': + optional: true + react-dom: + optional: true + react-native-gesture-handler: + optional: true + react-native-reanimated: + optional: true + react-native-web: + optional: true + react-server-dom-webpack: + optional: true + + expo-secure-store@55.0.14: + resolution: {integrity: sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ==} + peerDependencies: + expo: '*' + + expo-server@55.0.9: + resolution: {integrity: sha512-N5Ipn1NwqaJzEm+G97o0Jbe4g/th3R/16N1DabnYryXKCiZwDkK13/w3VfGkQN9LOOaBP+JIRxGf4M8lQKPzyA==} + engines: {node: '>=20.16.0'} + + expo-sharing@55.0.19: + resolution: {integrity: sha512-I1NC3ZPJvSpq8ptklUSUgh4xw9uxvqkqAhrdEHMr5qNxLEvCCReC+KFnySqamMjLKoZqqDJ2LeNnQxQxfauhfA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-symbols@55.0.8: + resolution: {integrity: sha512-Dg6BTu+fCWukdlh+3XYIr6NbqJWmK4aAQ6i6BInKnWU0ALuzVUJcMDq8Lk9bHok2hOh3OhzJqlCqEoBXPInIVQ==} + peerDependencies: + expo: '*' + expo-font: '*' + react: '*' + react-native: '*' + + expo-updates-interface@55.1.6: + resolution: {integrity: sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw==} + peerDependencies: + expo: '*' + + expo-video@55.0.17: + resolution: {integrity: sha512-z2Fqg1WkctD2jpsUoMQU9y6jWYlV+Lwb7nIMB+3fOcKMVCiUwSWS9xq/MQnRImZe6t9gfRFVMvw2Xz8Lk/kUPw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-web-browser@55.0.16: + resolution: {integrity: sha512-eeGs3439ewO/Q56Pzg3qbAVZSE0oH/R7XW9VCXI59k0m78ZIYbBtPT4PMFL/+sBgRkXm546Lq/DFcJQPTOfXJg==} + peerDependencies: + expo: '*' + react-native: '*' + + expo@55.0.24: + resolution: {integrity: sha512-nU95y+GIfD1dm9CSjsitDdltSU83dDqemxD1UUBxJPH8zKf7B5AdGVNyE6/jLWyCM/p/EmHfCeiqdrWCy9ljZA==} + hasBin: true + peerDependencies: + '@expo/dom-webview': '*' + '@expo/metro-runtime': '*' + react: '*' + react-native: '*' + react-native-webview: '*' + peerDependenciesMeta: + '@expo/dom-webview': + optional: true + '@expo/metro-runtime': + optional: true + react-native-webview: + optional: true + exponential-backoff@3.1.2: resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} @@ -10953,6 +12430,20 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fb-dotslash@0.5.8: + resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} + engines: {node: '>=20'} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -10980,6 +12471,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fetch-nodeshim@0.4.10: + resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} + fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} @@ -11031,10 +12525,18 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + filter-obj@5.1.0: resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} engines: {node: '>=14.16'} + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -11050,6 +12552,10 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -11080,6 +12586,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -11092,6 +12601,9 @@ packages: debug: optional: true + fontfaceobserver@2.3.0: + resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -11271,6 +12783,10 @@ packages: get-tsconfig@4.11.0: resolution: {integrity: sha512-sNsqf7XKQ38IawiVGPOoAlqZo1DMrO7TU+ZcZwi7yLl7/7S0JwmoBMKz/IkUPhSoXM0Ng3vT0yB1iCe5XavDeQ==} + getenv@2.0.0: + resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} + engines: {node: '>=6'} + gif.js@0.2.0: resolution: {integrity: sha512-bYxCoT8OZKmbxY8RN4qDiYuj4nrQDTzgLRcFVovyona1PTWNePzI4nzOmotnlOFIzTk/ZxAHtv+TfVLiBWj/hw==} @@ -11300,6 +12816,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -11457,12 +12977,39 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-compiler@0.14.1: + resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + hermes-estree@0.32.0: + resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + + hermes-estree@0.32.1: + resolution: {integrity: sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==} + + hermes-estree@0.33.3: + resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + + hermes-estree@0.35.0: + resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} + hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hermes-parser@0.32.0: + resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + + hermes-parser@0.32.1: + resolution: {integrity: sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==} + + hermes-parser@0.33.3: + resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + + hermes-parser@0.35.0: + resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + hls.js@0.14.17: resolution: {integrity: sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==} @@ -11472,6 +13019,9 @@ packages: hls.js@1.6.2: resolution: {integrity: sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.12.12: resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} engines: {node: '>=16.9.0'} @@ -11577,6 +13127,9 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -11607,14 +13160,15 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.4: - resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==} - engines: {node: '>= 4'} - ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-size@1.2.1: + resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} + engines: {node: '>=16.x'} + hasBin: true + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -11661,6 +13215,9 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + inspect-with-kind@1.0.5: resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} @@ -11675,6 +13232,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ioredis@5.6.1: resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} engines: {node: '>=12.22.0'} @@ -11970,6 +13530,10 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} @@ -12002,10 +13566,57 @@ packages: engines: {node: '>=10'} hasBin: true + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jimp-compact@0.16.1: + resolution: {integrity: sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -12059,6 +13670,9 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jsdoc-type-pratt-parser@4.1.0: resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==} engines: {node: '>=12.0.0'} @@ -12169,6 +13783,10 @@ packages: engines: {node: '>=8'} hasBin: true + lan-network@0.2.1: + resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} + hasBin: true + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -12186,6 +13804,10 @@ packages: leb@1.0.0: resolution: {integrity: sha512-Y3c3QZfvKWHX60BVOQPhLCvVGmDYWyJEiINE3drOog6KCyN2AOwvuQQzlS3uJg1J85kzpILXIUwRXULWavir+w==} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -12193,6 +13815,79 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -12224,6 +13919,10 @@ packages: resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} engines: {node: '>=14'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -12265,6 +13964,9 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} @@ -12274,6 +13976,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -12386,6 +14092,9 @@ packages: resolution: {integrity: sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA==} engines: {node: ^16.14.0 || >=18.0.0} + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -12401,6 +14110,9 @@ packages: engines: {node: '>= 16'} hasBin: true + marky@1.3.0: + resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -12458,6 +14170,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + media-chrome@4.12.0: resolution: {integrity: sha512-leItqyy2jn1nk66KGmzeH0z6JXeVUvK95O7ouQTSVdvrTXB/8jtLEI964eXlq5oCZfyPLnPUiuosKrDEfmWDqQ==} @@ -12478,6 +14193,12 @@ packages: mediabunny@1.45.2: resolution: {integrity: sha512-lm34wGClgC263x8SEH5+79Z6aeDcHetoCKMSAeqDhn6Qn4a3A24Bs8uJf9Lxt9h0MEa/uJqZ/5soial/V9TSwQ==} + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} @@ -12507,6 +14228,122 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + metro-babel-transformer@0.83.7: + resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==} + engines: {node: '>=20.19.4'} + + metro-babel-transformer@0.84.4: + resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache-key@0.83.7: + resolution: {integrity: sha512-W1c2Nmx8MiJTJt+eWhMO08z9VKi3kZOaz99IYGdqeqDgY9j+yZjXl62rUav4Di0heZfh4/n2s722PqRL1OODeg==} + engines: {node: '>=20.19.4'} + + metro-cache-key@0.84.4: + resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-cache@0.83.7: + resolution: {integrity: sha512-E9SRePXQ1Zvlj79VcOk57q7VC7rMHMFQ+jhmPHBiq+dJ0bJB5BL87lWZF6oh5X76Cci5tpDuQNaDwwuSCToEeg==} + engines: {node: '>=20.19.4'} + + metro-cache@0.84.4: + resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-config@0.83.7: + resolution: {integrity: sha512-83mjWFbFOt2GeJ6pFIum5mSnc1uTsZJAtD8o4ej0s4NVsYsA7fB+pHvTfHhFrpeMONaobu2riKavkPei05Er/Q==} + engines: {node: '>=20.19.4'} + + metro-config@0.84.4: + resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-core@0.83.7: + resolution: {integrity: sha512-6yn3w1wnltT6RQl7p7YES2l95ArC+mWrOssEiH8p5/DDrJS65/szf9LsC9JrBv8c5DdvSY3V3f0GRYg0Ox7hCg==} + engines: {node: '>=20.19.4'} + + metro-core@0.84.4: + resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.83.7: + resolution: {integrity: sha512-+j0F1m+FQYVAQ6syf+mwhIPV5GoFQrkInX8bppuc50IzNsZbMrp8R5H/Sx/K2daQ3YEa9F/XwkeZT8gzJfgeCw==} + engines: {node: '>=20.19.4'} + + metro-file-map@0.84.4: + resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-minify-terser@0.83.7: + resolution: {integrity: sha512-MfJar2IS4tBRuLb9svwb0Gu5l9BsH+pcRm8eGcEi/wy8MzZinfinh5dFLt2nWkocnulIgtGB5NkFDdbXqMXKhQ==} + engines: {node: '>=20.19.4'} + + metro-minify-terser@0.84.4: + resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-resolver@0.83.7: + resolution: {integrity: sha512-WSJIENlMcoSsuz66IfBHOkgfp3KJt2UW2TnEHPf1b8pIG2eEXNOVmo2+03A0H17WY2XGXWgxL0CG7FAopqgB1A==} + engines: {node: '>=20.19.4'} + + metro-resolver@0.84.4: + resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-runtime@0.83.7: + resolution: {integrity: sha512-9GKkJURaB2iyYoEExKnedzAHzxmKtSi+k0tsZUvMoU27tBZJElchYt7JH/Ai/XzYAI9lCAaV7u5HZSI8J5Z+wQ==} + engines: {node: '>=20.19.4'} + + metro-runtime@0.84.4: + resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-source-map@0.83.7: + resolution: {integrity: sha512-JgA1h7oc1a1jydBe1GhVFsUoMYo3wLPk7oRA32rjlDsq+sP2JLt9x2p2lWbNSxTm/u8NV4VRid3hvEJgcX8tKw==} + engines: {node: '>=20.19.4'} + + metro-source-map@0.84.4: + resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-symbolicate@0.83.7: + resolution: {integrity: sha512-g4suyxw20WOHWI680c+Kq4wC/NF+Hx5pRH9afrMp+sMTxqLeKcPR1Xf4wMhsjlbvx7LbIREdke6q928jEjvJWw==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro-symbolicate@0.84.4: + resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + + metro-transform-plugins@0.83.7: + resolution: {integrity: sha512-Ss0FpBiZDjX2kwhukMDl5sNdYK8T/06IPqxNE4H6PTlRlfs9q11cef13c/xESY/Pm4VCkp1yJUZO3kXzvMxQFA==} + engines: {node: '>=20.19.4'} + + metro-transform-plugins@0.84.4: + resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-transform-worker@0.83.7: + resolution: {integrity: sha512-UegCo7ygB2fT64mRK2nbAjQVJ1zSwIIHy8d96jJv2nKZFDaViYBiughEdu5HM/Ceq0WN3LZrZk3zhl9aoiLYFw==} + engines: {node: '>=20.19.4'} + + metro-transform-worker@0.84.4: + resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro@0.83.7: + resolution: {integrity: sha512-SPaPEyvTsTmd0LpT7RaZciQyDw2i/JB7+iY9L5VfBo72+psescFxBqpI1TL9dnL+pmnfkU+l/J1mEEGLeF65EQ==} + engines: {node: '>=20.19.4'} + hasBin: true + + metro@0.84.4: + resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + micro-api-client@3.3.0: resolution: {integrity: sha512-y0y6CUB9RLVsy3kfgayU28746QrNMpSm9O/AYGNsBgOkJr/X/Jk0VLGoO8Ude7Bpa8adywzF+MzXNZRFRsNPhg==} @@ -12650,6 +14487,10 @@ packages: engines: {node: '>=16'} hasBin: true + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -12742,6 +14583,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -12841,6 +14686,9 @@ packages: multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + multitars@1.0.0: + resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -13010,6 +14858,10 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.1.1: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true @@ -13039,6 +14891,9 @@ packages: node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + node-source-walk@6.0.2: resolution: {integrity: sha512-jn9vOIK/nfqoFCcpK89/VCVaLg1IHE6UVfDOzvqmANaJ/rWCTEdH8RZ1V278nv2jr36BJdyQXIAavBLXpzdlag==} engines: {node: '>=14'} @@ -13126,6 +14981,12 @@ packages: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} deprecated: This package is no longer supported. + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + number-flow@0.5.7: resolution: {integrity: sha512-P83Y9rBgN3Xpz5677YDNtuQHZpIldw6WXeWRg0+edrfFthhV7QqRdABas5gtu07QPLvbA8XhfO69rIvbKRzYIg==} @@ -13140,6 +15001,14 @@ packages: oauth@0.9.15: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + ob1@0.83.7: + resolution: {integrity: sha512-9M5kpuOLyTPogMtZiQUIxdAZxl7Dxs6tVBbJErSumsqGMuhVSoUbkfeZ3XNPpLpwBBtqY5QDUzGwggLHX3slQg==} + engines: {node: '>=20.19.4'} + + ob1@0.84.4: + resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -13205,16 +15074,28 @@ packages: resolution: {integrity: sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==} engines: {node: ^10.13.0 || >=12.0.0} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -13240,6 +15121,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + open@8.4.0: resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} engines: {node: '>=12'} @@ -13262,6 +15147,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + ora@8.2.0: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} @@ -13286,6 +15175,10 @@ packages: resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -13294,6 +15187,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -13318,6 +15215,10 @@ packages: resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} engines: {node: '>=14.16'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + p-wait-for@5.0.2: resolution: {integrity: sha512-lwx6u1CotQYPVju77R+D0vFomni/AqRfqLmqQ8hekklqZ6gAY9rONh7lBQ0uxWMkC2AuX9b2DVAl8To0NyP1JA==} engines: {node: '>=12'} @@ -13368,6 +15269,10 @@ packages: parse-numeric-range@1.3.0: resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + parse-png@2.1.0: + resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} + engines: {node: '>=10'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -13413,6 +15318,10 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -13502,10 +15411,18 @@ packages: player.style@0.1.8: resolution: {integrity: sha512-r/k2gX1IS3tIH/MQJsXQ0pt54s0NqQ70jSePQSKBlu1Rwf/qqdFUmSKACL10ebS48B0VioclwbvKzBnqgb52Tw==} + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -13589,6 +15506,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} @@ -13642,9 +15563,17 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -13689,6 +15618,12 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -13743,6 +15678,10 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + querystring@0.2.0: resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} engines: {node: '>=0.4.x'} @@ -13751,6 +15690,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -13794,11 +15736,19 @@ packages: peerDependencies: react: ^16.3.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 + react-devtools-core@6.1.5: + resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} + react-dom@19.1.1: resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} peerDependencies: react: ^19.1.1 + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -13820,6 +15770,15 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-freeze@1.0.4: + resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=17.0.0' + react-hls-player@3.0.7: resolution: {integrity: sha512-i5QWNyLmaUhV/mgnpljRJT0CBfJnylClV/bne8aiXO3ZqU0+D3U/jtTDwdXM4i5qHhyFy9lemyZ179IgadKd0Q==} peerDependencies: @@ -13854,6 +15813,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react-loading-skeleton@3.5.0: resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} peerDependencies: @@ -13865,6 +15830,73 @@ packages: '@types/react': '>=18' react: '>=18' + react-native-gesture-handler@2.30.1: + resolution: {integrity: sha512-xIUBDo5ktmJs++0fZlavQNvDEE4PsihWhSeJsJtoz4Q6p0MiTM9TgrTgfEgzRR36qGPytFoeq+ShLrVwGdpUdA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.2.1: + resolution: {integrity: sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-is-edge-to-edge@1.3.1: + resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-reanimated@4.2.1: + resolution: {integrity: sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-worklets: 0.7.4 + + react-native-safe-area-context@5.6.2: + resolution: {integrity: sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-screens@4.23.0: + resolution: {integrity: sha512-XhO3aK0UeLpBn4kLecd+J+EDeRRJlI/Ro9Fze06vo1q163VeYtzfU9QS09/VyDFMWR1qxDC1iazCArTPSFFiPw==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-svg@15.15.5: + resolution: {integrity: sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-web@0.21.2: + resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-native-worklets@0.7.4: + resolution: {integrity: sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag==} + peerDependencies: + '@babel/core': '*' + react: '*' + react-native: '*' + + react-native@0.83.6: + resolution: {integrity: sha512-H513+8VzviNFXOdPnStRzX9S3/jiJGg++QZ1zd+ROyAvBEKqFqKUPHH0d82y3QyRPct5qKjdOa7J6vNehCvXYA==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-promise-suspense@0.3.4: resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} @@ -13880,6 +15912,10 @@ packages: redux: optional: true + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -13950,6 +15986,11 @@ packages: '@types/react': optional: true + react-test-renderer@19.2.0: + resolution: {integrity: sha512-zLCFMHFE9vy/w3AxO0zNxy6aAupnCuLSVOJYDe/Tp+ayGI1f2PLQsFVPANSD42gdSbmYx5oN+1VWDhcXtq7hAQ==} + peerDependencies: + react: ^19.2.0 + react-tooltip@5.28.1: resolution: {integrity: sha512-ZA4oHwoIIK09TS7PvSLFcRlje1wGZaxw6xHvfrzn6T82UcMEfEmHVCad16Gnr4NDNDh93HyN037VK4HDi5odfQ==} peerDependencies: @@ -13960,6 +16001,10 @@ packages: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -14060,6 +16105,16 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regex-recursion@5.1.1: resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} @@ -14083,6 +16138,17 @@ packages: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} + hasBin: true + rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} @@ -14156,11 +16222,19 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-workspace-root@2.0.1: + resolution: {integrity: sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true @@ -14172,6 +16246,10 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -14333,6 +16411,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -14364,6 +16447,10 @@ packages: seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -14412,6 +16499,13 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sf-symbols-typescript@2.2.0: + resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} + engines: {node: '>=10'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -14471,6 +16565,9 @@ packages: resolution: {integrity: sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==} engines: {node: ^16.14.0 || >=18.0.0} + simple-plist@1.3.1: + resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -14493,6 +16590,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -14591,6 +16692,10 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -14623,6 +16728,10 @@ packages: spdx-license-ids@3.0.21: resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -14687,18 +16796,30 @@ packages: stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + stacktrace-parser@0.1.11: + resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} + engines: {node: '>=6'} + stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -14752,9 +16873,17 @@ packages: prettier: optional: true + stream-buffers@2.2.0: + resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} + engines: {node: '>= 0.10.0'} + streamx@2.22.0: resolution: {integrity: sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==} + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -14799,6 +16928,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -14853,6 +16986,9 @@ packages: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} + structured-headers@0.4.1: + resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} + style-to-js@1.1.16: resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==} @@ -14875,6 +17011,9 @@ packages: babel-plugin-macros: optional: true + styleq@0.1.3: + resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + subtitles-parser-vtt@0.1.0: resolution: {integrity: sha512-+y3GOvLL+71JLMFFjqSi4p0J9ddSbhpXKaWG6vHUT8PqPZmlhyAsfu0LP248FdVGfwNIj77wIgVkfQ2xwCZ4+Q==} @@ -14902,6 +17041,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + supports-hyperlinks@4.4.0: resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} engines: {node: '>=20'} @@ -14967,6 +17110,10 @@ packages: tauri-plugin-positioner-api@0.2.7: resolution: {integrity: sha512-jwqRHo59UU3aJbffEFkWVhBorjQg1WNeDa4W4eWVnaTqLals+/fqgHdNwTGzG1+LLdaJSS2FUy4XSwEDAWvERQ==} + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + terminal-link@5.0.0: resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} engines: {node: '>=20'} @@ -15003,6 +17150,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + test-exclude@7.0.1: resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} engines: {node: '>=18'} @@ -15023,6 +17174,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -15098,6 +17252,9 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -15117,6 +17274,9 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + toqr@0.1.1: + resolution: {integrity: sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -15305,6 +17465,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -15313,6 +17477,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -15356,6 +17524,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + ua-parser-js@1.0.41: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true @@ -15440,6 +17613,22 @@ packages: unenv@2.0.0-rc.15: resolution: {integrity: sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==} + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -15671,6 +17860,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} @@ -15704,6 +17899,11 @@ packages: peerDependencies: react: '>=16.8.0' + use-latest-callback@0.2.6: + resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} + peerDependencies: + react: '>=16.8' + use-resize-observer@9.1.0: resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} peerDependencies: @@ -15744,6 +17944,11 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@7.0.3: + resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + uuid@8.0.0: resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -15787,6 +17992,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -16024,6 +18235,9 @@ packages: jsdom: optional: true + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -16031,6 +18245,12 @@ packages: walk-up-path@3.0.1: resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warn-once@0.1.1: + resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -16113,10 +18333,16 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -16238,6 +18464,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -16246,6 +18476,18 @@ packages: resolution: {integrity: sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==} engines: {node: ^18.17.0 || >=20.5.0} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.17.1: resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} engines: {node: '>=10.0.0'} @@ -16298,6 +18540,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xcode@3.0.1: + resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + engines: {node: '>=10.0.0'} + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -16310,6 +18556,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml2js@0.6.0: + resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} + engines: {node: '>=4.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -16318,6 +18568,10 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -17588,8 +19842,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.27.2': {} + '@babel/compat-data@7.29.3': {} + '@babel/core@7.27.1': dependencies: '@ampproject/remapping': 2.3.0 @@ -17610,15 +19872,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.27.1': - dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.1 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/generator@7.27.5': + '@babel/generator@7.28.3': dependencies: '@babel/parser': 7.28.4 '@babel/types': 7.28.4 @@ -17626,14 +19880,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@7.28.3': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.27.2 @@ -17642,36 +19900,132 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.27.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.18.6': dependencies: - '@babel/types': 7.27.6 + '@babel/types': 7.29.0 - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 - '@babel/helper-module-imports': 7.27.1 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.27.1 '@babel/traverse': 7.28.4 transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helpers@7.27.1': dependencies: '@babel/template': 7.27.2 @@ -17686,7 +20040,7 @@ snapshots: '@babel/parser@7.27.2': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 '@babel/parser@7.27.5': dependencies: @@ -17696,16 +20050,362 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-export-default-from@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.27.1) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.4(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.27.1) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.27.1) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.27.1) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.27.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.27.1) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -17716,6 +20416,114 @@ snapshots: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.27.1) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.27.1) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.27.1) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.27.1) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-react@7.28.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.27.1': {} '@babel/standalone@7.27.2': {} @@ -17723,16 +20531,22 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.5 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 '@babel/traverse@7.27.1': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 - '@babel/parser': 7.27.5 - '@babel/template': 7.27.2 - '@babel/types': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 debug: 4.4.3(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: @@ -17741,7 +20555,7 @@ snapshots: '@babel/traverse@7.27.4': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 + '@babel/generator': 7.29.1 '@babel/parser': 7.27.5 '@babel/template': 7.27.2 '@babel/types': 7.27.6 @@ -17753,7 +20567,7 @@ snapshots: '@babel/traverse@7.28.4': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.4 '@babel/template': 7.27.2 @@ -17762,6 +20576,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.1': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -17777,6 +20603,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} '@biomejs/biome@2.2.0': @@ -17871,7 +20702,7 @@ snapshots: optionalDependencies: workerd: 1.20250408.0 - '@cloudflare/vitest-pool-workers@0.6.16(@cloudflare/workers-types@4.20250507.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0))': + '@cloudflare/vitest-pool-workers@0.6.16(@cloudflare/workers-types@4.20250507.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0))': dependencies: '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -17881,7 +20712,7 @@ snapshots: esbuild: 0.17.19 miniflare: 3.20250204.1 semver: 7.7.1 - vitest: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0) + vitest: 2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0) wrangler: 3.109.1(@cloudflare/workers-types@4.20250507.0) zod: 3.25.76 transitivePeerDependencies: @@ -18131,6 +20962,10 @@ snapshots: '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) effect: 3.18.4 + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -18143,12 +20978,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.9.1': - dependencies: - '@emnapi/wasi-threads': 1.2.0 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -18169,21 +20998,11 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -18881,6 +21700,353 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@expo-google-fonts/material-symbols@0.4.38': {} + + '@expo/cli@55.0.30(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 55.0.17(typescript@5.9.3) + '@expo/config-plugins': 55.0.9 + '@expo/devcert': 1.2.1 + '@expo/env': 2.1.2 + '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/json-file': 10.0.14 + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.1 + '@expo/metro-config': 55.0.21(expo@55.0.24)(typescript@5.9.3) + '@expo/osascript': 2.4.3 + '@expo/package-manager': 1.10.5 + '@expo/plist': 0.5.3 + '@expo/prebuild-config': 55.0.18(expo@55.0.24)(typescript@5.9.3) + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.11)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.4 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.83.6 + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3(supports-color@8.1.1) + dnssd-advertise: 1.1.4 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-server: 55.0.9 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.3 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.7.4 + send: 0.19.0 + slugify: 1.6.9 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.18.3 + zod: 3.25.76 + optionalDependencies: + expo-router: 55.0.14(eed5efedde241c317111b390e3f7dd2b) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + + '@expo/code-signing-certificates@0.0.6': + dependencies: + node-forge: 1.4.0 + + '@expo/config-plugins@55.0.9': + dependencies: + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.14 + '@expo/plist': 0.5.3 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.7.4 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + + '@expo/config-types@55.0.5': {} + + '@expo/config@55.0.17(typescript@5.9.3)': + dependencies: + '@expo/config-plugins': 55.0.9 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.14 + '@expo/require-utils': 55.0.5(typescript@5.9.3) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/devcert@1.2.1': + dependencies: + '@expo/sudo-prompt': 9.3.2 + debug: 3.2.7 + transitivePeerDependencies: + - supports-color + + '@expo/devtools@55.0.3(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + '@expo/dom-webview@55.0.6(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + '@expo/env@2.1.2': + dependencies: + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + getenv: 2.0.0 + transitivePeerDependencies: + - supports-color + + '@expo/fingerprint@0.16.7': + dependencies: + '@expo/env': 2.1.2 + '@expo/spawn-async': 1.7.2 + arg: 5.0.2 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + getenv: 2.0.0 + glob: 13.0.6 + ignore: 5.3.2 + minimatch: 10.2.4 + resolve-from: 5.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + '@expo/image-utils@0.8.14(typescript@5.9.3)': + dependencies: + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + getenv: 2.0.0 + jimp-compact: 0.16.1 + parse-png: 2.1.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/json-file@10.0.14': + dependencies: + '@babel/code-frame': 7.27.1 + json5: 2.2.3 + + '@expo/local-build-cache-provider@55.0.13(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.17(typescript@5.9.3) + chalk: 4.1.2 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/log-box@55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/dom-webview': 55.0.6(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + + '@expo/metro-config@55.0.21(expo@55.0.24)(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.27.1 + '@babel/generator': 7.28.3 + '@expo/config': 55.0.17(typescript@5.9.3) + '@expo/env': 2.1.2 + '@expo/json-file': 10.0.14 + '@expo/metro': 55.1.1 + '@expo/spawn-async': 1.7.2 + browserslist: 4.26.3 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.32.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - typescript + - utf-8-validate + + '@expo/metro-runtime@55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + anser: 1.4.10 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@expo/dom-webview' + + '@expo/metro@55.1.1': + dependencies: + metro: 0.83.7 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + metro-file-map: 0.83.7 + metro-minify-terser: 0.83.7 + metro-resolver: 0.83.7 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + metro-symbolicate: 0.83.7 + metro-transform-plugins: 0.83.7 + metro-transform-worker: 0.83.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@expo/osascript@2.4.3': + dependencies: + '@expo/spawn-async': 1.7.2 + + '@expo/package-manager@1.10.5': + dependencies: + '@expo/json-file': 10.0.14 + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + resolve-workspace-root: 2.0.1 + + '@expo/plist@0.5.3': + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.18(expo@55.0.24)(typescript@5.9.3)': + dependencies: + '@expo/config': 55.0.17(typescript@5.9.3) + '@expo/config-plugins': 55.0.9 + '@expo/config-types': 55.0.5 + '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/json-file': 10.0.14 + '@react-native/normalize-colors': 0.83.6 + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/require-utils@55.0.5(typescript@5.9.3)': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.27.1 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@55.0.16(@expo/metro-runtime@55.0.11)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.9 + react: 19.2.0 + optionalDependencies: + '@expo/metro-runtime': 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-router: 55.0.14(eed5efedde241c317111b390e3f7dd2b) + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - supports-color + + '@expo/schema-utils@55.0.4': {} + + '@expo/sdk-runtime-versions@1.0.0': {} + + '@expo/spawn-async@1.7.2': + dependencies: + cross-spawn: 7.0.6 + + '@expo/sudo-prompt@9.3.2': {} + + '@expo/vector-icons@15.1.1(expo-font@55.0.7)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + expo-font: 55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + '@expo/ws-tunnel@1.0.6': {} + + '@expo/xcpretty@4.4.4': + dependencies: + '@babel/code-frame': 7.27.1 + chalk: 4.1.2 + js-yaml: 4.1.0 + '@fastify/busboy@2.1.1': {} '@fastify/busboy@3.1.1': {} @@ -19220,8 +22386,79 @@ snapshots: '@isaacs/string-locale-compare@1.1.0': {} + '@isaacs/ttlcache@1.4.1': {} + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + '@istanbuljs/schema@0.1.3': {} + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.4.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.21 + jest-mock: 29.7.0 + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.21 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/get-type@30.1.0': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.27.1 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.21 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -19238,7 +22475,6 @@ snapshots: dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - optional: true '@jridgewell/source-map@0.3.6': dependencies: @@ -19279,9 +22515,9 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)))': + '@kobalte/tailwindcss@0.9.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)))': dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) '@kobalte/utils@0.9.1(solid-js@1.9.6)': dependencies: @@ -19537,8 +22773,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.9': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.9.0 optional: true @@ -19630,7 +22866,7 @@ snapshots: '@netlify/zip-it-and-ship-it@10.1.0(encoding@0.1.13)(rollup@4.40.2)': dependencies: - '@babel/parser': 7.28.4 + '@babel/parser': 7.29.3 '@babel/types': 7.27.1 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 1.41.1 @@ -19792,7 +23028,7 @@ snapshots: promise-all-reject-late: 1.0.1 promise-call-limit: 3.0.2 read-package-json-fast: 3.0.2 - semver: 7.7.2 + semver: 7.7.4 ssri: 10.0.6 treeverse: 3.0.0 walk-up-path: 3.0.1 @@ -19802,7 +23038,7 @@ snapshots: '@npmcli/fs@3.1.1': dependencies: - semver: 7.7.3 + semver: 7.7.4 '@npmcli/git@5.0.8': dependencies: @@ -19836,7 +23072,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 pacote: 18.0.6 proc-log: 4.2.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - bluebird - supports-color @@ -19853,7 +23089,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 normalize-package-data: 6.0.2 proc-log: 4.2.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - bluebird @@ -20221,7 +23457,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.13.1 require-in-the-middle: 7.5.2 - semver: 7.7.3 + semver: 7.7.4 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -20315,7 +23551,7 @@ snapshots: '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - semver: 7.7.2 + semver: 7.7.4 '@opentelemetry/sdk-trace-node@2.1.0(@opentelemetry/api@1.9.0)': dependencies: @@ -20570,16 +23806,16 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@pulumi/github@6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumi/github@6.7.2(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: - bluebird - supports-color - ts-node - typescript - '@pulumi/pulumi@3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumi/pulumi@3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@grpc/grpc-js': 1.13.3 '@logdna/tail-file': 2.2.0 @@ -20610,15 +23846,15 @@ snapshots: tmp: 0.2.5 upath: 1.2.0 optionalDependencies: - ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3) - typescript: 5.8.3 + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - bluebird - supports-color - '@pulumiverse/vercel@1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3)': + '@pulumiverse/vercel@1.14.3(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))(typescript@5.8.3) + '@pulumi/pulumi': 3.201.0(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: - bluebird - supports-color @@ -20639,6 +23875,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -20669,6 +23907,18 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -20686,6 +23936,12 @@ snapshots: '@babel/runtime': 7.27.1 react: 19.2.4 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -20697,6 +23953,12 @@ snapshots: '@babel/runtime': 7.27.1 react: 19.2.4 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -20725,6 +23987,28 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@radix-ui/react-dialog@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-portal': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.4 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.6.3(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dialog@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -20747,6 +24031,12 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -20777,6 +24067,19 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dismissable-layer@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dismissable-layer@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -20810,6 +24113,12 @@ snapshots: '@babel/runtime': 7.27.1 react: 19.2.4 + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -20825,6 +24134,17 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-focus-scope@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -20853,6 +24173,13 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4) react: 19.2.4 + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -20983,6 +24310,16 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-portal@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -21011,6 +24348,16 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -21021,6 +24368,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@babel/runtime': 7.27.1 @@ -21028,6 +24385,15 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@radix-ui/react-primitive@2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-slot': 1.2.2(@types/react@19.2.14)(react@19.2.4) @@ -21037,6 +24403,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) @@ -21046,6 +24421,23 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-roving-focus@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -21117,6 +24509,13 @@ snapshots: '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) react: 19.2.4 + '@radix-ui/react-slot@1.2.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.2(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -21124,6 +24523,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -21146,6 +24552,22 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-tooltip@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -21171,6 +24593,12 @@ snapshots: '@babel/runtime': 7.27.1 react: 19.2.4 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -21183,6 +24611,14 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) react: 19.2.4 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) @@ -21191,6 +24627,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -21204,6 +24647,13 @@ snapshots: '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) react: 19.2.4 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -21216,6 +24666,12 @@ snapshots: '@babel/runtime': 7.27.1 react: 19.2.4 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -21486,6 +24942,283 @@ snapshots: dependencies: react: 19.2.4 + '@react-native/assets-registry@0.83.6': {} + + '@react-native/babel-plugin-codegen@0.83.6(@babel/core@7.27.1)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.83.6(@babel/core@7.27.1) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-plugin-codegen@0.85.3(@babel/core@7.27.1)': + dependencies: + '@babel/traverse': 7.29.0 + '@react-native/codegen': 0.85.3(@babel/core@7.27.1) + transitivePeerDependencies: + - '@babel/core' + - supports-color + optional: true + + '@react-native/babel-preset@0.83.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.27.1) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.27.1) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.1) + '@babel/template': 7.28.6 + '@react-native/babel-plugin-codegen': 0.83.6(@babel/core@7.27.1) + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.27.1) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/babel-preset@0.85.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.27.1) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.1) + '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.27.1) + babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.27.1) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/codegen@0.83.6(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/parser': 7.29.3 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + + '@react-native/codegen@0.85.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/parser': 7.29.3 + hermes-parser: 0.33.3 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.15 + yargs: 17.7.2 + optional: true + + '@react-native/community-cli-plugin@0.83.6(@react-native/metro-config@0.85.3(@babel/core@7.27.1))': + dependencies: + '@react-native/dev-middleware': 0.83.6 + debug: 4.4.3(supports-color@8.1.1) + invariant: 2.2.4 + metro: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + semver: 7.7.4 + optionalDependencies: + '@react-native/metro-config': 0.85.3(@babel/core@7.27.1) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/debugger-frontend@0.83.6': {} + + '@react-native/debugger-shell@0.83.6': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + + '@react-native/dev-middleware@0.83.6': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.6 + '@react-native/debugger-shell': 0.83.6 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.3(supports-color@8.1.1) + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.2 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/gradle-plugin@0.83.6': {} + + '@react-native/js-polyfills@0.83.6': {} + + '@react-native/js-polyfills@0.85.3': + optional: true + + '@react-native/metro-babel-transformer@0.85.3(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@react-native/babel-preset': 0.85.3(@babel/core@7.27.1) + hermes-parser: 0.33.3 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@react-native/metro-config@0.85.3(@babel/core@7.27.1)': + dependencies: + '@react-native/js-polyfills': 0.85.3 + '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.27.1) + metro-config: 0.84.4 + metro-runtime: 0.84.4 + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - supports-color + - utf-8-validate + optional: true + + '@react-native/normalize-colors@0.74.89': {} + + '@react-native/normalize-colors@0.83.6': {} + + '@react-native/virtualized-lists@0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@react-navigation/bottom-tabs@7.16.1(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.18(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/core@7.17.4(react@19.2.0)': + dependencies: + '@react-navigation/routers': 7.5.5 + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-is: 19.2.6 + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.5.0(react@19.2.0) + + '@react-navigation/elements@2.9.18(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/native': 7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + use-sync-external-store: 1.5.0(react@19.2.0) + + '@react-navigation/native-stack@7.15.1(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/elements': 2.9.18(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + color: 4.2.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + sf-symbols-typescript: 2.2.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-navigation/core': 7.17.4(react@19.2.0) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + use-latest-callback: 0.2.6(react@19.2.0) + + '@react-navigation/routers@7.5.5': + dependencies: + nanoid: 3.3.11 + '@reduxjs/toolkit@2.10.1(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.0.0 @@ -21626,7 +25359,7 @@ snapshots: estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) is-reference: 1.2.1 - magic-string: 0.30.19 + magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: rollup: 4.40.2 @@ -21635,7 +25368,7 @@ snapshots: dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.40.2) estree-walker: 2.0.2 - magic-string: 0.30.19 + magic-string: 0.30.21 optionalDependencies: rollup: 4.40.2 @@ -21658,7 +25391,7 @@ snapshots: '@rollup/plugin-replace@6.0.2(rollup@4.40.2)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.40.2) - magic-string: 0.30.19 + magic-string: 0.30.21 optionalDependencies: rollup: 4.40.2 @@ -21678,7 +25411,7 @@ snapshots: dependencies: '@types/estree': 1.0.7 estree-walker: 2.0.2 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 4.40.2 @@ -21848,6 +25581,13 @@ snapshots: '@shinyoshiaki/jspack@0.0.6': {} + '@shopify/flash-list@2.0.2(@babel/runtime@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.27.1 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + '@sigstore/bundle@2.3.2': dependencies: '@sigstore/protobuf-specs': 0.3.3 @@ -21880,6 +25620,10 @@ snapshots: '@sigstore/core': 1.1.0 '@sigstore/protobuf-specs': 0.3.3 + '@sinclair/typebox@0.27.10': {} + + '@sinclair/typebox@0.34.49': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@5.6.0': {} @@ -21888,6 +25632,14 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@smithy/abort-controller@4.0.2': dependencies: '@smithy/types': 4.3.1 @@ -22921,11 +26673,11 @@ snapshots: dependencies: solid-js: 1.9.6 - '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': + '@solidjs/start@1.1.3(@testing-library/jest-dom@6.5.0)(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(solid-js@1.9.6)(terser@5.44.0)(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)': dependencies: - '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@tanstack/server-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) defu: 6.1.4 error-stack-parser: 2.1.4 html-to-image: 1.11.13 @@ -22936,8 +26688,8 @@ snapshots: source-map-js: 1.2.1 terracotta: 1.0.6(solid-js@1.9.6) tinyglobby: 0.2.13 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@testing-library/jest-dom' - '@types/node' @@ -23066,12 +26818,12 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/builder-vite@10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/builder-vite@10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: - '@storybook/csf-plugin': 10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/csf-plugin': 10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) storybook: 8.6.12(prettier@3.7.4) ts-dedent: 2.2.0 - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - esbuild - rollup @@ -23087,7 +26839,7 @@ snapshots: jsdoc-type-pratt-parser: 4.1.0 process: 0.11.10 recast: 0.23.11 - semver: 7.7.2 + semver: 7.7.4 util: 0.12.5 ws: 8.18.3 optionalDependencies: @@ -23098,14 +26850,14 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': + '@storybook/csf-plugin@10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5))': dependencies: storybook: 8.6.12(prettier@3.7.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.5 rollup: 4.40.2 - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) webpack: 5.101.3(esbuild@0.25.5) '@storybook/csf-plugin@8.6.12(storybook@8.6.12(prettier@3.7.4))': @@ -23312,6 +27064,12 @@ snapshots: valibot: 1.0.0-rc.1(typescript@5.8.3) zod: 3.25.76 + '@t3-oss/env-core@0.12.0(typescript@5.9.3)(valibot@1.0.0-rc.1(typescript@5.9.3))(zod@3.25.76)': + optionalDependencies: + typescript: 5.9.3 + valibot: 1.0.0-rc.1(typescript@5.9.3) + zod: 3.25.76 + '@t3-oss/env-nextjs@0.12.0(typescript@5.8.3)(valibot@1.0.0-rc.1(typescript@5.8.3))(zod@3.25.76)': dependencies: '@t3-oss/env-core': 0.12.0(typescript@5.8.3)(valibot@1.0.0-rc.1(typescript@5.8.3))(zod@3.25.76) @@ -23320,6 +27078,14 @@ snapshots: valibot: 1.0.0-rc.1(typescript@5.8.3) zod: 3.25.76 + '@t3-oss/env-nextjs@0.12.0(typescript@5.9.3)(valibot@1.0.0-rc.1(typescript@5.9.3))(zod@3.25.76)': + dependencies: + '@t3-oss/env-core': 0.12.0(typescript@5.9.3)(valibot@1.0.0-rc.1(typescript@5.9.3))(zod@3.25.76) + optionalDependencies: + typescript: 5.9.3 + valibot: 1.0.0-rc.1(typescript@5.9.3) + zod: 3.25.76 + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)))': dependencies: lodash.castarray: 4.4.0 @@ -23328,13 +27094,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)))': + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) '@tanstack/devtools-event-bus@0.3.2': dependencies: @@ -23364,20 +27130,20 @@ snapshots: - csstype - utf-8-validate - '@tanstack/directive-functions-plugin@1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)': + '@tanstack/directive-functions-plugin@1.119.2(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.1) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.1) - '@babel/template': 7.27.2 - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@tanstack/router-utils': 1.115.0 babel-dead-code-elimination: 1.0.10 dedent: 1.6.0 tiny-invariant: 1.3.3 - vite: 6.1.4(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.1.4(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -23444,12 +27210,12 @@ snapshots: '@tanstack/router-utils@1.115.0': dependencies: - '@babel/generator': 7.28.3 - '@babel/parser': 7.28.4 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 ansis: 3.17.0 diff: 7.0.0 - '@tanstack/server-functions-plugin@1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)': + '@tanstack/server-functions-plugin@1.119.2(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.27.1 @@ -23458,7 +27224,7 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.27.1 '@babel/types': 7.27.1 - '@tanstack/directive-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + '@tanstack/directive-functions-plugin': 1.119.2(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) babel-dead-code-elimination: 1.0.10 dedent: 1.6.0 tiny-invariant: 1.3.3 @@ -23664,6 +27430,16 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/react-native@13.3.3(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + jest-matcher-utils: 30.4.1 + picocolors: 1.1.1 + pretty-format: 30.4.1 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-test-renderer: 19.2.0(react@19.2.0) + redent: 3.0.0 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -23750,16 +27526,16 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.4 '@types/body-parser@1.19.5': dependencies: @@ -23883,6 +27659,12 @@ snapshots: '@types/google-protobuf@3.15.12': {} + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.21 + + '@types/hammerjs@2.0.46': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -23893,6 +27675,16 @@ snapshots: '@types/http-errors@2.0.4': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + '@types/js-cookie@3.0.6': {} '@types/jsdom@21.1.7': @@ -23990,6 +27782,10 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-test-renderer@19.1.0': + dependencies: + '@types/react': 19.2.14 + '@types/react-tooltip@4.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: react-tooltip: 5.28.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -24026,6 +27822,8 @@ snapshots: '@types/shimmer@1.2.0': {} + '@types/stack-utils@2.0.3': {} + '@types/tmp@0.2.6': {} '@types/tough-cookie@4.0.5': {} @@ -24045,27 +27843,33 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.19.21 optional: true - '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.4.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare-lite: 1.4.0 semver: 7.7.1 - tsutils: 3.21.0(typescript@5.8.3) + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -24085,15 +27889,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) debug: 4.4.0 eslint: 8.57.1 optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -24132,15 +27936,15 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/type-utils@5.62.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 - tsutils: 3.21.0(typescript@5.8.3) + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -24160,17 +27964,17 @@ snapshots: '@typescript-eslint/types@8.57.2': {} - '@typescript-eslint/typescript-estree@5.62.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.7.1 - tsutils: 3.21.0(typescript@5.8.3) + semver: 7.7.4 + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -24189,17 +27993,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.7.0 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) eslint: 8.57.1 eslint-scope: 5.1.1 - semver: 7.7.1 + semver: 7.7.4 transitivePeerDependencies: - supports-color - typescript @@ -24318,8 +28122,8 @@ snapshots: dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.40.2) - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) async-sema: 3.1.1 bindings: 1.5.0 estree-walker: 2.0.2 @@ -24396,7 +28200,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: '@babel/parser': 7.27.2 acorn: 8.14.1 @@ -24407,18 +28211,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) - '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': + '@vinxi/server-components@0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1)) acorn: 8.14.1 acorn-loose: 8.5.0 acorn-typescript: 1.4.13(acorn@8.14.1) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) + vinxi: 0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1) '@virtual-grid/core@2.0.1': {} @@ -24436,14 +28240,14 @@ snapshots: '@virtual-grid/shared@2.0.1': {} - '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))': + '@vitejs/plugin-react@4.4.1(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -24462,7 +28266,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -24488,21 +28292,29 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.17)(terser@5.44.0))': + '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 optionalDependencies: - vite: 5.4.19(@types/node@22.15.17)(terser@5.44.0) + vite: 5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0) - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 - magic-string: 0.30.19 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 optionalDependencies: - vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) '@vitest/pretty-format@2.0.5': dependencies: @@ -24530,13 +28342,13 @@ snapshots: '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.17 + magic-string: 0.30.21 pathe: 1.1.2 '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 '@vitest/spy@2.0.5': @@ -24560,13 +28372,13 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) '@vitest/utils@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 estree-walker: 3.0.3 - loupe: 3.1.3 + loupe: 3.2.1 tinyrainbow: 1.2.0 '@vitest/utils@2.1.9': @@ -24821,7 +28633,7 @@ snapshots: - aws-crt - supports-color - '@workflow/next@4.0.1-beta.69(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@workflow/next@4.0.1-beta.69(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@swc/core': 1.15.3(@swc/helpers@0.5.17) '@workflow/builders': 4.0.1-beta.64(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17) @@ -24830,7 +28642,7 @@ snapshots: semver: 7.7.4 watchpack: 2.5.1 optionalDependencies: - next: 16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@opentelemetry/api' - '@swc/helpers' @@ -24951,9 +28763,9 @@ snapshots: ulid: 3.0.1 zod: 4.3.6 - '@workos-inc/node@7.50.0(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@workos-inc/node@7.50.0(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: - iron-session: 6.3.1(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + iron-session: 6.3.1(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) jose: 5.6.3 leb: 1.0.0 pluralize: 8.0.0 @@ -25045,6 +28857,10 @@ snapshots: dependencies: arch: 3.0.0 + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': {} + '@xtuc/ieee754@1.2.0': optional: true @@ -25075,6 +28891,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -25161,6 +28981,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + anser@1.4.10: {} + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -25175,6 +28997,8 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -25376,6 +29200,8 @@ snapshots: dependencies: printable-characters: 1.0.42 + asap@2.0.6: {} + asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -25386,7 +29212,7 @@ snapshots: ast-kit@2.1.3: dependencies: - '@babel/parser': 7.28.4 + '@babel/parser': 7.29.3 pathe: 2.0.3 ast-module-types@5.0.0: {} @@ -25469,22 +29295,159 @@ snapshots: babel-dead-code-elimination@1.0.10: dependencies: '@babel/core': 7.27.1 - '@babel/parser': 7.27.5 - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.1 + '@babel/parser': 7.29.3 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + babel-jest@29.7.0(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.27.1) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 transitivePeerDependencies: - supports-color + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.7 + babel-plugin-jsx-dom-expressions@0.39.8(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) - '@babel/types': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.1) + '@babel/types': 7.29.0 html-entities: 2.3.3 parse5: 7.3.0 validate-html-nesting: 1.2.2 + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.27.1): + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/core': 7.27.1 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.27.1) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.27.1) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.27.1) + transitivePeerDependencies: + - supports-color + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.0 + + babel-plugin-react-native-web@0.21.2: {} + + babel-plugin-syntax-hermes-parser@0.32.0: + dependencies: + hermes-parser: 0.32.0 + + babel-plugin-syntax-hermes-parser@0.32.1: + dependencies: + hermes-parser: 0.32.1 + + babel-plugin-syntax-hermes-parser@0.33.3: + dependencies: + hermes-parser: 0.33.3 + optional: true + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.27.1): + dependencies: + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.27.1) + transitivePeerDependencies: + - '@babel/core' + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.1) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.27.1) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.27.1) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.27.1) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.27.1) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.1) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.1) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.27.1) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.27.1) + + babel-preset-expo@55.0.21(@babel/core@7.27.1)(@babel/runtime@7.27.1)(expo@55.0.24)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.27.1) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-syntax-export-default-from': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.27.1) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.27.1) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.27.1) + '@babel/preset-react': 7.28.5(@babel/core@7.27.1) + '@babel/preset-typescript': 7.28.5(@babel/core@7.27.1) + '@react-native/babel-preset': 0.83.6(@babel/core@7.27.1) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.32.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.27.1) + debug: 4.4.3(supports-color@8.1.1) + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.27.1 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-jest@29.6.3(@babel/core@7.27.1): + dependencies: + '@babel/core': 7.27.1 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.27.1) + babel-preset-solid@1.9.6(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 @@ -25505,7 +29468,7 @@ snapshots: baseline-browser-mapping@2.10.11: {} - baseline-browser-mapping@2.8.16: {} + baseline-browser-mapping@2.10.30: {} before-after-hook@2.2.3: {} @@ -25517,6 +29480,8 @@ snapshots: bezier-easing@2.1.0: {} + big-integer@1.6.52: {} + bin-links@4.0.4: dependencies: cmd-shim: 6.0.3 @@ -25577,7 +29542,7 @@ snapshots: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3(supports-color@8.1.1) - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.6.3 on-finished: 2.4.1 qs: 6.14.0 @@ -25586,6 +29551,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + bottleneck@2.19.5: {} bowser@2.11.0: {} @@ -25594,13 +29561,25 @@ snapshots: dependencies: ansi-align: 3.0.1 camelcase: 8.0.0 - chalk: 5.4.1 + chalk: 5.6.2 cli-boxes: 3.0.0 string-width: 7.2.0 type-fest: 4.41.0 widest-line: 5.0.0 wrap-ansi: 9.0.0 + bplist-creator@0.1.0: + dependencies: + stream-buffers: 2.2.0 + + bplist-parser@0.3.1: + dependencies: + big-integer: 1.6.52 + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -25629,12 +29608,24 @@ snapshots: browserslist@4.26.3: dependencies: - baseline-browser-mapping: 2.8.16 + baseline-browser-mapping: 2.10.11 caniuse-lite: 1.0.30001750 electron-to-chromium: 1.5.234 node-releases: 2.0.23 update-browserslist-db: 1.1.3(browserslist@4.26.3) + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.30 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.357 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -25774,12 +29765,18 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001717: {} caniuse-lite@1.0.30001750: {} + caniuse-lite@1.0.30001793: {} + canvas-confetti@1.9.3: {} caseless@0.12.0: {} @@ -25819,7 +29816,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.3 + loupe: 3.2.1 pathval: 2.0.0 chalk@2.4.2: @@ -25878,9 +29875,33 @@ snapshots: chromatic@11.28.2: {} + chrome-launcher@0.15.2: + dependencies: + '@types/node': 20.19.21 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + chrome-trace-event@1.0.4: optional: true + chromium-edge-launcher@0.2.0: + dependencies: + '@types/node': 20.19.21 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + + ci-info@2.0.0: {} + + ci-info@3.9.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -25901,6 +29922,10 @@ snapshots: cli-boxes@3.0.0: {} + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -25925,8 +29950,7 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: - optional: true + clone@1.0.4: {} clsx@1.2.1: {} @@ -25974,7 +29998,6 @@ snapshots: dependencies: color-convert: 2.0.1 color-string: 1.9.1 - optional: true colorspace@1.1.4: dependencies: @@ -25989,6 +30012,8 @@ snapshots: commander@10.0.1: {} + commander@12.1.0: {} + commander@13.1.0: {} commander@2.20.3: {} @@ -25997,6 +30022,8 @@ snapshots: commander@6.2.1: {} + commander@7.2.0: {} + commander@8.3.0: {} common-ancestor-path@1.0.1: {} @@ -26022,6 +30049,22 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -26044,6 +30087,15 @@ snapshots: confbox@0.2.2: {} + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + consola@3.4.2: {} console-control-strings@1.1.0: {} @@ -26079,6 +30131,10 @@ snapshots: '@types/cookie': 0.6.0 cookie: 0.7.2 + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + core-js@3.42.0: {} core-util-is@1.0.3: {} @@ -26137,6 +30193,25 @@ snapshots: dependencies: uncrypto: 0.1.3 + css-in-js-utils@3.1.0: + dependencies: + hyphenate-style-name: 1.1.0 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -26277,6 +30352,8 @@ snapshots: dependencies: character-entities: 2.0.2 + decode-uri-component@0.2.2: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -26320,7 +30397,6 @@ snapshots: defaults@1.0.4: dependencies: clone: 1.0.4 - optional: true defaults@2.0.2: {} @@ -26405,10 +30481,10 @@ snapshots: detective-typescript@11.2.0: dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) ast-module-types: 5.0.0 node-source-walk: 6.0.2 - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -26439,6 +30515,8 @@ snapshots: dependencies: '@leichtgewicht/ip-codec': 2.0.5 + dnssd-advertise@1.1.4: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -26567,6 +30645,8 @@ snapshots: electron-to-chromium@1.5.234: {} + electron-to-chromium@1.5.357: {} + emoji-regex-xs@1.0.0: {} emoji-regex@10.4.0: {} @@ -26821,7 +30901,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 + acorn: 8.16.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.2 @@ -27063,6 +31143,8 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -27075,20 +31157,20 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@13.3.0(eslint@8.57.1)(typescript@5.8.3): + eslint-config-next@13.3.0(eslint@8.57.1)(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 13.3.0 '@rushstack/eslint-patch': 1.11.0 - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.3 transitivePeerDependencies: - eslint-import-resolver-webpack - eslint-plugin-import-x @@ -27142,7 +31224,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.7.2 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -27161,11 +31243,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) @@ -27183,7 +31265,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -27194,7 +31276,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -27206,7 +31288,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -27346,11 +31428,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-tailwindcss@3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))): + eslint-plugin-tailwindcss@3.18.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3))): dependencies: fast-glob: 3.3.3 postcss: 8.5.3 - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3)) eslint-plugin-turbo@1.13.4(eslint@8.57.1): dependencies: @@ -27590,7 +31672,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -27652,6 +31734,273 @@ snapshots: expect-type@1.2.1: {} + expo-asset@55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@expo/image-utils': 0.8.14(typescript@5.9.3) + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + - typescript + + expo-clipboard@55.0.13(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-constants@55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + '@expo/env': 2.1.2 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + + expo-dev-client@55.0.34(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-dev-launcher: 55.0.35(expo@55.0.24) + expo-dev-menu: 55.0.29(expo@55.0.24) + expo-dev-menu-interface: 55.0.2(expo@55.0.24) + expo-manifests: 55.0.17(expo@55.0.24) + expo-updates-interface: 55.1.6(expo@55.0.24) + + expo-dev-launcher@55.0.35(expo@55.0.24): + dependencies: + '@expo/schema-utils': 55.0.4 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-dev-menu: 55.0.29(expo@55.0.24) + expo-manifests: 55.0.17(expo@55.0.24) + + expo-dev-menu-interface@55.0.2(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-dev-menu@55.0.29(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-dev-menu-interface: 55.0.2(expo@55.0.24) + + expo-document-picker@55.0.13(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-file-system@55.0.20(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-font@55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + fontfaceobserver: 2.3.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-glass-effect@55.0.11(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-image-loader@55.0.0(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-image-picker@55.0.20(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-image-loader: 55.0.0(expo@55.0.24) + + expo-image@55.0.10(expo@55.0.24)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + + expo-json-utils@55.0.2: {} + + expo-keep-awake@55.0.8(expo@55.0.24)(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + + expo-linking@55.0.15(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - expo + - supports-color + + expo-manifests@55.0.17(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-json-utils: 55.0.2 + + expo-media-library@55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-modules-autolinking@55.0.22(typescript@5.9.3): + dependencies: + '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/spawn-async': 1.7.2 + chalk: 4.1.2 + commander: 7.2.0 + transitivePeerDependencies: + - supports-color + - typescript + + expo-modules-core@55.0.25(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + react-native-worklets: 0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + + expo-router@55.0.14(eed5efedde241c317111b390e3f7dd2b): + dependencies: + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/schema-utils': 55.0.4 + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-navigation/bottom-tabs': 7.16.1(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native': 7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@react-navigation/native-stack': 7.15.1(@react-navigation/native@7.2.4(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + client-only: 0.0.1 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-glass-effect: 55.0.11(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-image: 55.0.10(expo@55.0.24)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-linking: 55.0.15(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-server: 55.0.9 + expo-symbols: 55.0.8(expo-font@55.0.7)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.0 + react-fast-compare: 3.2.2 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-safe-area-context: 5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-screens: 4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + optionalDependencies: + '@testing-library/react-native': 13.3.3(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0) + react-dom: 19.2.0(react@19.2.0) + react-native-gesture-handler: 2.30.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-reanimated: 4.2.1(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-web: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + + expo-secure-store@55.0.14(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-server@55.0.9: {} + + expo-sharing@55.0.19(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@expo/config-plugins': 55.0.9 + '@expo/config-types': 55.0.5 + '@expo/plist': 0.5.3 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + transitivePeerDependencies: + - supports-color + + expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.38 + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-font: 55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + sf-symbols-typescript: 2.2.0 + + expo-updates-interface@55.1.6(expo@55.0.24): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-video@55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo-web-browser@55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + expo@55.0.24(@babel/core@7.27.1)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.27.1 + '@expo/cli': 55.0.30(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + '@expo/config': 55.0.17(typescript@5.9.3) + '@expo/config-plugins': 55.0.9 + '@expo/devtools': 55.0.3(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/fingerprint': 0.16.7 + '@expo/local-build-cache-provider': 55.0.13(typescript@5.9.3) + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro': 55.1.1 + '@expo/metro-config': 55.0.21(expo@55.0.24)(typescript@5.9.3) + '@expo/vector-icons': 15.1.1(expo-font@55.0.7)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 55.0.21(@babel/core@7.27.1)(@babel/runtime@7.27.1)(expo@55.0.24)(react-refresh@0.14.2) + expo-asset: 55.0.17(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-constants: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-file-system: 55.0.20(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0)) + expo-font: 55.0.7(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + expo-keep-awake: 55.0.8(expo@55.0.24)(react@19.2.0) + expo-modules-autolinking: 55.0.22(typescript@5.9.3) + expo-modules-core: 55.0.25(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + pretty-format: 29.7.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/dom-webview': 55.0.6(expo@55.0.24)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@expo/metro-runtime': 55.0.11(@expo/dom-webview@55.0.6)(expo@55.0.24)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-dom + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + exponential-backoff@3.1.2: {} express-rate-limit@7.5.1(express@5.1.0): @@ -27674,7 +32023,7 @@ snapshots: etag: 1.8.1 finalhandler: 1.3.2 fresh: 0.5.2 - http-errors: 2.0.0 + http-errors: 2.0.1 merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 @@ -27813,6 +32162,26 @@ snapshots: dependencies: reusify: 1.1.0 + fb-dotslash@0.5.8: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0(encoding@0.1.13) + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -27840,6 +32209,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fetch-nodeshim@0.4.10: {} + fflate@0.4.8: {} fflate@0.8.2: {} @@ -27899,8 +32270,22 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@1.1.0: {} + filter-obj@5.1.0: {} + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -27928,6 +32313,11 @@ snapshots: find-up-simple@1.0.1: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -27950,7 +32340,7 @@ snapshots: fix-dts-default-cjs-exports@1.0.1: dependencies: - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.40.2 @@ -27967,10 +32357,14 @@ snapshots: flatted@3.3.3: {} + flow-enums-runtime@0.0.6: {} + fn.name@1.1.0: {} follow-redirects@1.15.9: {} + fontfaceobserver@2.3.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -28074,9 +32468,9 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 - geist@1.4.2(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + geist@1.4.2(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: - next: 16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) generate-function@2.3.1: dependencies: @@ -28144,6 +32538,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + getenv@2.0.0: {} + gif.js@0.2.0: {} giget@2.0.0: @@ -28183,6 +32579,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.0 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.1.7: dependencies: fs.realpath: 1.0.0 @@ -28239,7 +32641,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 2.3.0 fast-glob: 3.3.3 - ignore: 7.0.4 + ignore: 7.0.5 path-type: 6.0.0 slash: 5.1.0 unicorn-magic: 0.3.0 @@ -28453,12 +32855,40 @@ snapshots: property-information: 7.0.0 space-separated-tokens: 2.0.2 + hermes-compiler@0.14.1: {} + hermes-estree@0.25.1: {} + hermes-estree@0.32.0: {} + + hermes-estree@0.32.1: {} + + hermes-estree@0.33.3: + optional: true + + hermes-estree@0.35.0: {} + hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + hermes-parser@0.32.0: + dependencies: + hermes-estree: 0.32.0 + + hermes-parser@0.32.1: + dependencies: + hermes-estree: 0.32.1 + + hermes-parser@0.33.3: + dependencies: + hermes-estree: 0.33.3 + optional: true + + hermes-parser@0.35.0: + dependencies: + hermes-estree: 0.35.0 + hls.js@0.14.17: dependencies: eventemitter3: 4.0.7 @@ -28468,6 +32898,10 @@ snapshots: hls.js@1.6.2: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + hono@4.12.12: {} hono@4.7.4: {} @@ -28584,6 +33018,8 @@ snapshots: dependencies: ms: 2.1.3 + hyphenate-style-name@1.1.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -28608,10 +33044,12 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.4: {} - ignore@7.0.5: {} + image-size@1.2.1: + dependencies: + queue: 6.0.2 + immediate@3.0.6: {} immer@10.2.0: {} @@ -28649,6 +33087,10 @@ snapshots: inline-style-parser@0.2.4: {} + inline-style-prefixer@7.0.1: + dependencies: + css-in-js-utils: 3.1.0 + inspect-with-kind@1.0.5: dependencies: kind-of: 6.0.3 @@ -28663,6 +33105,10 @@ snapshots: internmap@2.0.3: {} + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + ioredis@5.6.1: dependencies: '@ioredis/commands': 1.2.0 @@ -28686,7 +33132,7 @@ snapshots: ipaddr.js@1.9.1: {} - iron-session@6.3.1(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + iron-session@6.3.1(express@5.1.0)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: '@peculiar/webcrypto': 1.5.0 '@types/cookie': 0.5.4 @@ -28697,7 +33143,7 @@ snapshots: iron-webcrypto: 0.2.8 optionalDependencies: express: 5.1.0 - next: 16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) iron-webcrypto@0.2.8: dependencies: @@ -28922,6 +33368,16 @@ snapshots: istanbul-lib-coverage@3.2.2: {} + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.27.1 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 @@ -28968,6 +33424,85 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.21 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.21 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.21 + jest-util: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.21 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + jest-worker@27.5.1: dependencies: '@types/node': 20.19.21 @@ -28975,6 +33510,15 @@ snapshots: supports-color: 8.1.1 optional: true + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.21 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jimp-compact@0.16.1: {} + jiti@1.21.7: {} jiti@2.4.2: {} @@ -29010,6 +33554,8 @@ snapshots: jsbn@1.1.0: {} + jsc-safe-url@0.2.4: {} + jsdoc-type-pratt-parser@4.1.0: {} jsdom@26.1.0: @@ -29116,6 +33662,8 @@ snapshots: dotenv: 16.5.0 winston: 3.17.0 + lan-network@0.2.1: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -29130,6 +33678,8 @@ snapshots: leb@1.0.0: {} + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -29139,6 +33689,62 @@ snapshots: dependencies: immediate: 3.0.6 + lighthouse-logger@1.4.2: + dependencies: + debug: 2.6.9 + marky: 1.3.0 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -29182,6 +33788,10 @@ snapshots: pkg-types: 2.1.0 quansync: 0.2.10 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -29212,12 +33822,18 @@ snapshots: lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} + lodash.truncate@4.4.2: {} lodash.union@4.6.0: {} lodash@4.17.21: {} + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -29301,8 +33917,8 @@ snapshots: magicast@0.2.11: dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 recast: 0.23.11 magicast@0.3.5: @@ -29317,7 +33933,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 make-error@1.3.6: optional: true @@ -29339,6 +33955,10 @@ snapshots: transitivePeerDependencies: - supports-color + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + map-or-similar@1.5.0: {} markdown-extensions@2.0.0: {} @@ -29347,6 +33967,8 @@ snapshots: marked@7.0.4: {} + marky@1.3.0: {} + math-intrinsics@1.1.0: {} md-to-react-email@5.0.5(react@19.1.1): @@ -29522,6 +34144,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.0.14: {} + media-chrome@4.12.0(react@19.2.4): dependencies: ce-la-react: 0.3.0(react@19.2.4) @@ -29546,6 +34170,10 @@ snapshots: '@types/dom-mediacapture-transform': 0.1.11 '@types/dom-webcodecs': 0.1.13 + memoize-one@5.2.1: {} + + memoize-one@6.0.0: {} + memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 @@ -29568,6 +34196,368 @@ snapshots: methods@1.1.2: {} + metro-babel-transformer@0.83.7: + dependencies: + '@babel/core': 7.27.1 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.83.7 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-babel-transformer@0.84.4: + dependencies: + '@babel/core': 7.27.1 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + optional: true + + metro-cache-key@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache-key@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + optional: true + + metro-cache@0.83.7: + dependencies: + exponential-backoff: 3.1.2 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.83.7 + transitivePeerDependencies: + - supports-color + + metro-cache@0.84.4: + dependencies: + exponential-backoff: 3.1.2 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.4 + transitivePeerDependencies: + - supports-color + optional: true + + metro-config@0.83.7: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.7 + metro-cache: 0.83.7 + metro-core: 0.83.7 + metro-runtime: 0.83.7 + yaml: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.4: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.4 + metro-cache: 0.84.4 + metro-core: 0.84.4 + metro-runtime: 0.84.4 + yaml: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + metro-core@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.83.7 + + metro-core@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.4 + optional: true + + metro-file-map@0.83.7: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + + metro-file-map@0.84.4: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + optional: true + + metro-minify-terser@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.44.0 + + metro-minify-terser@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.44.0 + optional: true + + metro-resolver@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-resolver@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + optional: true + + metro-runtime@0.83.7: + dependencies: + '@babel/runtime': 7.27.1 + flow-enums-runtime: 0.0.6 + + metro-runtime@0.84.4: + dependencies: + '@babel/runtime': 7.27.1 + flow-enums-runtime: 0.0.6 + optional: true + + metro-source-map@0.83.7: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.83.7 + nullthrows: 1.1.1 + ob1: 0.83.7 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-source-map@0.84.4: + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.4 + nullthrows: 1.1.1 + ob1: 0.84.4 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + metro-symbolicate@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.83.7 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + optional: true + + metro-transform-plugins@0.83.7: + dependencies: + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.84.4: + dependencies: + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + optional: true + + metro-transform-worker@0.83.7: + dependencies: + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.83.7 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-minify-terser: 0.83.7 + metro-source-map: 0.83.7 + metro-transform-plugins: 0.83.7 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-transform-worker@0.84.4: + dependencies: + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + flow-enums-runtime: 0.0.6 + metro: 0.84.4 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-minify-terser: 0.84.4 + metro-source-map: 0.84.4 + metro-transform-plugins: 0.84.4 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + + metro@0.83.7: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3(supports-color@8.1.1) + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.83.7 + metro-cache: 0.83.7 + metro-cache-key: 0.83.7 + metro-config: 0.83.7 + metro-core: 0.83.7 + metro-file-map: 0.83.7 + metro-resolver: 0.83.7 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + metro-symbolicate: 0.83.7 + metro-transform-plugins: 0.83.7 + metro-transform-worker: 0.83.7 + mime-types: 3.0.1 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.84.4: + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3(supports-color@8.1.1) + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + image-size: 1.2.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.4 + metro-cache: 0.84.4 + metro-cache-key: 0.84.4 + metro-config: 0.84.4 + metro-core: 0.84.4 + metro-file-map: 0.84.4 + metro-resolver: 0.84.4 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + metro-symbolicate: 0.84.4 + metro-transform-plugins: 0.84.4 + metro-transform-worker: 0.84.4 + mime-types: 3.0.1 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.10 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + micro-api-client@3.3.0: {} micromark-core-commonmark@2.0.3: @@ -29857,6 +34847,8 @@ snapshots: mime@4.0.7: {} + mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -29959,6 +34951,8 @@ snapshots: minipass@7.1.2: {} + minipass@7.1.3: {} + minizlib@2.1.2: dependencies: minipass: 3.3.6 @@ -30057,6 +35051,8 @@ snapshots: multipasta@0.2.7: {} + multitars@1.0.0: {} + mustache@4.2.0: {} mux-embed@5.9.0: {} @@ -30115,13 +35111,13 @@ snapshots: p-wait-for: 5.0.2 qs: 6.14.0 - next-auth@4.24.11(next@15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + next: 15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.26.6 @@ -30132,13 +35128,13 @@ snapshots: optionalDependencies: nodemailer: 6.10.1 - next-auth@4.24.11(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@6.10.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next-auth@4.24.11(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@6.10.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@babel/runtime': 7.27.1 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.26.6 @@ -30164,7 +35160,7 @@ snapshots: - acorn - supports-color - next@15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next@15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@next/env': 15.5.9 '@swc/helpers': 0.5.15 @@ -30183,12 +35179,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 15.5.7 '@next/swc-win32-x64-msvc': 15.5.7 '@opentelemetry/api': 1.9.0 + babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.9 '@swc/helpers': 0.5.15 @@ -30207,12 +35204,13 @@ snapshots: '@next/swc-win32-arm64-msvc': 15.5.7 '@next/swc-win32-x64-msvc': 15.5.7 '@opentelemetry/api': 1.9.0 + babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.1 '@swc/helpers': 0.5.15 @@ -30232,6 +35230,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.1 '@next/swc-win32-x64-msvc': 16.2.1 '@opentelemetry/api': 1.9.0 + babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -30277,7 +35276,7 @@ snapshots: klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 - magic-string: 0.30.17 + magic-string: 0.30.21 magicast: 0.3.5 mime: 4.0.7 mlly: 1.7.4 @@ -30293,7 +35292,7 @@ snapshots: rollup: 4.40.2 rollup-plugin-visualizer: 5.14.0(rolldown@1.0.1)(rollup@4.40.2) scule: 1.3.0 - semver: 7.7.2 + semver: 7.7.4 serve-placeholder: 2.0.2 serve-static: 2.2.0 source-map: 0.7.4 @@ -30375,6 +35374,8 @@ snapshots: node-forge@1.3.1: {} + node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.1.1: dependencies: detect-libc: 2.1.2 @@ -30410,9 +35411,11 @@ snapshots: node-releases@2.0.23: {} + node-releases@2.0.44: {} + node-source-walk@6.0.2: dependencies: - '@babel/parser': 7.28.4 + '@babel/parser': 7.29.3 nodemailer@6.10.1: {} @@ -30431,7 +35434,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.2 + semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-path@2.1.1: @@ -30452,7 +35455,7 @@ snapshots: npm-install-checks@6.3.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 npm-normalize-package-bin@3.0.1: {} @@ -30460,7 +35463,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-name: 5.0.1 npm-packlist@8.0.2: @@ -30472,7 +35475,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.3 - semver: 7.7.3 + semver: 7.7.4 npm-registry-fetch@17.1.0: dependencies: @@ -30502,6 +35505,12 @@ snapshots: gauge: 3.0.2 set-blocking: 2.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nullthrows@1.1.1: {} + number-flow@0.5.7: dependencies: esm-env: 1.2.2 @@ -30518,6 +35527,15 @@ snapshots: oauth@0.9.15: {} + ob1@0.83.7: + dependencies: + flow-enums-runtime: 0.0.6 + + ob1@0.84.4: + dependencies: + flow-enums-runtime: 0.0.6 + optional: true + object-assign@4.1.1: {} object-hash@2.2.0: {} @@ -30596,10 +35614,16 @@ snapshots: oidc-token-hash@5.1.1: {} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + on-finished@2.4.1: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -30608,6 +35632,10 @@ snapshots: dependencies: fn.name: 1.1.0 + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -30641,6 +35669,11 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + open@8.4.0: dependencies: define-lazy-prop: 2.0.0 @@ -30686,9 +35719,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + ora@8.2.0: dependencies: - chalk: 5.4.1 + chalk: 5.6.2 cli-cursor: 5.0.0 cli-spinners: 2.9.2 is-interactive: 2.0.0 @@ -30714,6 +35756,10 @@ snapshots: dependencies: p-timeout: 5.1.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -30722,6 +35768,10 @@ snapshots: dependencies: yocto-queue: 1.2.1 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -30740,6 +35790,8 @@ snapshots: p-timeout@6.1.4: {} + p-try@2.2.0: {} + p-wait-for@5.0.2: dependencies: p-timeout: 6.1.4 @@ -30807,12 +35859,16 @@ snapshots: parse-json@8.3.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 index-to-position: 1.1.0 type-fest: 4.41.0 parse-numeric-range@1.3.0: {} + parse-png@2.1.0: + dependencies: + pngjs: 3.4.0 + parse5@7.3.0: dependencies: entities: 6.0.0 @@ -30848,6 +35904,11 @@ snapshots: lru-cache: 11.1.0 minipass: 7.1.2 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.3 + path-to-regexp@0.1.13: {} path-to-regexp@6.3.0: {} @@ -30920,8 +35981,16 @@ snapshots: transitivePeerDependencies: - react + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + pngjs@3.4.0: {} + polished@4.3.1: dependencies: '@babel/runtime': 7.27.1 @@ -30956,13 +36025,21 @@ snapshots: postcss: 8.5.3 ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3) - postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)): + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.1 + optionalDependencies: + postcss: 8.5.3 + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3) + + postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.7.1 optionalDependencies: postcss: 8.5.3 - ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.3)(yaml@2.8.1): dependencies: @@ -31006,6 +36083,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.4.49: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.3: dependencies: nanoid: 3.3.11 @@ -31065,8 +36148,21 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-format@3.8.0: {} + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.6 + printable-characters@1.0.42: {} prismjs@1.30.0: {} @@ -31092,6 +36188,14 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + promise@7.3.1: + dependencies: + asap: 2.0.6 + + promise@8.3.0: + dependencies: + asap: 2.0.6 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -31154,10 +36258,21 @@ snapshots: quansync@0.2.11: {} + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + querystring@0.2.0: {} queue-microtask@1.2.3: {} + queue@6.0.2: + dependencies: + inherits: 2.0.4 + quick-lru@5.1.1: {} quote-unquote@1.0.0: {} @@ -31204,11 +36319,24 @@ snapshots: react: 19.2.4 tween-functions: 1.2.0 + react-devtools-core@6.1.5: + dependencies: + shell-quote: 1.8.3 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + react-dom@19.1.1(react@19.1.1): dependencies: react: 19.1.1 scheduler: 0.26.0 + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -31221,7 +36349,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-email@4.0.16(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + react-email@4.0.16(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@babel/parser': 7.27.5 '@babel/traverse': 7.27.4 @@ -31233,7 +36361,7 @@ snapshots: glob: 11.0.3 log-symbols: 7.0.1 mime-types: 3.0.1 - next: 15.5.9(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + next: 15.5.9(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) normalize-path: 3.0.0 ora: 8.2.0 socket.io: 4.8.1 @@ -31273,6 +36401,12 @@ snapshots: - supports-color - utf-8-validate + react-fast-compare@3.2.2: {} + + react-freeze@1.0.4(react@19.2.0): + dependencies: + react: 19.2.0 + react-hls-player@3.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: hls.js: 0.14.17 @@ -31300,6 +36434,10 @@ snapshots: react-is@17.0.2: {} + react-is@18.3.1: {} + + react-is@19.2.6: {} + react-loading-skeleton@3.5.0(react@19.2.4): dependencies: react: 19.2.4 @@ -31322,6 +36460,133 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-gesture-handler@2.30.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.2.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + react-native-is-edge-to-edge@1.3.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + react-native-reanimated@4.2.1(react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + react-native-worklets: 0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + semver: 7.7.3 + + react-native-safe-area-context@5.6.2(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + react-native-screens@4.23.0(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-freeze: 1.0.4(react@19.2.0) + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + warn-once: 0.1.1 + + react-native-svg@15.15.5(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + + react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.27.1 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + + react-native-worklets@0.7.4(@babel/core@7.27.1)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.27.1) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.1) + '@babel/preset-typescript': 7.27.1(@babel/core@7.27.1) + convert-source-map: 2.0.0 + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0) + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.83.6 + '@react-native/codegen': 0.83.6(@babel/core@7.27.1) + '@react-native/community-cli-plugin': 0.83.6(@react-native/metro-config@0.85.3(@babel/core@7.27.1)) + '@react-native/gradle-plugin': 0.83.6 + '@react-native/js-polyfills': 0.83.6 + '@react-native/normalize-colors': 0.83.6 + '@react-native/virtualized-lists': 0.83.6(@types/react@19.2.14)(react-native@0.83.6(@babel/core@7.27.1)(@react-native/metro-config@0.85.3(@babel/core@7.27.1))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.27.1) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.1 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.7 + metro-source-map: 0.83.7 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.0 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.4 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + react-promise-suspense@0.3.4: dependencies: fast-deep-equal: 2.0.1 @@ -31335,8 +36600,18 @@ snapshots: '@types/react': 19.2.14 redux: 5.0.1 + react-refresh@0.14.2: {} + react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -31356,6 +36631,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-remove-scroll@2.6.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + react-remove-scroll@2.6.3(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -31405,6 +36691,14 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: get-nonce: 1.0.1 @@ -31413,6 +36707,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-test-renderer@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-is: 19.2.6 + scheduler: 0.27.0 + react-tooltip@5.28.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@floating-ui/dom': 1.7.0 @@ -31422,6 +36722,8 @@ snapshots: react@19.1.1: {} + react@19.2.0: {} + react@19.2.4: {} read-cache@1.0.0: @@ -31493,7 +36795,7 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + recharts@3.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.6)(react@19.2.4)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.10.1(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) clsx: 2.1.1 @@ -31503,7 +36805,7 @@ snapshots: immer: 10.2.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-is: 17.0.2 + react-is: 19.2.6 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -31573,6 +36875,14 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.13.11: {} + regex-recursion@5.1.1: dependencies: regex: 5.1.1 @@ -31603,6 +36913,21 @@ snapshots: regexpp@3.2.0: {} + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.1: + dependencies: + jsesc: 3.1.0 + rehype-parse@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -31714,12 +37039,21 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-workspace-root@2.0.1: {} + resolve@1.22.10: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + resolve@2.0.0-next.5: dependencies: is-core-module: 2.16.1 @@ -31734,6 +37068,11 @@ snapshots: dependencies: lowercase-keys: 3.0.0 + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -31749,7 +37088,7 @@ snapshots: rolldown-plugin-dts@0.16.11(rolldown@1.0.1)(typescript@5.8.3): dependencies: - '@babel/generator': 7.28.3 + '@babel/generator': 7.29.1 '@babel/parser': 7.28.4 '@babel/types': 7.28.4 ast-kit: 2.1.3 @@ -31757,7 +37096,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) dts-resolver: 2.1.2 get-tsconfig: 4.11.0 - magic-string: 0.30.19 + magic-string: 0.30.21 rolldown: 1.0.1 optionalDependencies: typescript: 5.8.3 @@ -31765,6 +37104,24 @@ snapshots: - oxc-resolver - supports-color + rolldown-plugin-dts@0.16.11(rolldown@1.0.1)(typescript@5.9.3): + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + ast-kit: 2.1.3 + birpc: 2.6.1 + debug: 4.4.3(supports-color@8.1.1) + dts-resolver: 2.1.2 + get-tsconfig: 4.11.0 + magic-string: 0.30.21 + rolldown: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + - supports-color + rolldown@1.0.0-beta.42: dependencies: '@oxc-project/types': 0.94.0 @@ -31946,6 +37303,8 @@ snapshots: semver@6.3.1: {} + semver@7.6.3: {} + semver@7.7.1: {} semver@7.7.2: {} @@ -31979,7 +37338,7 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 @@ -31990,6 +37349,8 @@ snapshots: seq-queue@0.0.5: {} + serialize-error@2.1.0: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -32052,11 +37413,15 @@ snapshots: setprototypeof@1.2.0: {} + sf-symbols-typescript@2.2.0: {} + + shallowequal@1.1.0: {} + sharp@0.33.5: dependencies: color: 4.2.3 detect-libc: 2.0.4 - semver: 7.7.1 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -32188,6 +37553,12 @@ snapshots: transitivePeerDependencies: - supports-color + simple-plist@1.3.1: + dependencies: + bplist-creator: 0.1.0 + bplist-parser: 0.3.1 + plist: 3.1.1 + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 @@ -32210,6 +37581,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slugify@1.6.9: {} + smart-buffer@4.2.0: {} smob@1.5.0: {} @@ -32300,8 +37673,8 @@ snapshots: solid-refresh@0.6.3(solid-js@1.9.6): dependencies: - '@babel/generator': 7.27.1 - '@babel/helper-module-imports': 7.27.1 + '@babel/generator': 7.29.1 + '@babel/helper-module-imports': 7.28.6 '@babel/types': 7.27.1 solid-js: 1.9.6 transitivePeerDependencies: @@ -32341,6 +37714,8 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.7: {} + source-map@0.6.1: {} source-map@0.7.4: {} @@ -32367,6 +37742,8 @@ snapshots: spdx-license-ids@3.0.21: {} + split-on-first@1.1.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: {} @@ -32424,10 +37801,18 @@ snapshots: stack-trace@0.0.10: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} stackframe@1.3.4: {} + stacktrace-parser@0.1.11: + dependencies: + type-fest: 0.7.1 + stacktracey@2.1.8: dependencies: as-table: 1.0.55 @@ -32435,6 +37820,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@1.5.0: {} + statuses@2.0.1: {} statuses@2.0.2: {} @@ -32450,16 +37837,16 @@ snapshots: stoppable@1.1.0: {} - storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): + storybook-solidjs-vite@1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(esbuild@0.25.5)(rollup@4.40.2)(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4))(vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)): dependencies: - '@storybook/builder-vite': 10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) + '@storybook/builder-vite': 10.5.0-alpha.0(esbuild@0.25.5)(rollup@4.40.2)(storybook@8.6.12(prettier@3.7.4))(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1))(webpack@5.101.3(esbuild@0.25.5)) '@storybook/types': 9.0.0-alpha.1(storybook@8.6.12(prettier@3.7.4)) magic-string: 0.30.17 solid-js: 1.9.6 storybook: 8.6.12(prettier@3.7.4) storybook-solidjs: 1.0.0-beta.7(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.7.4)))(solid-js@1.9.6)(storybook@8.6.12(prettier@3.7.4)) - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + vite-plugin-solid: 2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@storybook/test' - esbuild @@ -32490,6 +37877,8 @@ snapshots: - supports-color - utf-8-validate + stream-buffers@2.2.0: {} + streamx@2.22.0: dependencies: fast-fifo: 1.3.2 @@ -32497,6 +37886,8 @@ snapshots: optionalDependencies: bare-events: 2.5.4 + strict-uri-encode@2.0.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -32578,6 +37969,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -32626,6 +38021,8 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 + structured-headers@0.4.1: {} + style-to-js@1.1.16: dependencies: style-to-object: 1.0.8 @@ -32648,6 +38045,8 @@ snapshots: client-only: 0.0.1 react: 19.2.4 + styleq@0.1.3: {} + subtitles-parser-vtt@0.1.0: {} sucrase@3.35.0: @@ -32676,6 +38075,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + supports-hyperlinks@4.4.0: dependencies: has-flag: 5.0.1 @@ -32701,9 +38105,9 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))): + tailwind-scrollbar@3.1.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3))): dependencies: @@ -32713,9 +38117,9 @@ snapshots: dependencies: tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)) - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.8.3)): dependencies: @@ -32771,7 +38175,34 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-import: 15.1.0(postcss@8.5.3) + postcss-js: 4.0.1(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3)) + postcss-nested: 6.2.0(postcss@8.5.3) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -32790,7 +38221,7 @@ snapshots: postcss: 8.5.3 postcss-import: 15.1.0(postcss@8.5.3) postcss-js: 4.0.1(postcss@8.5.3) - postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3)) + postcss-load-config: 4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.3) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -32840,6 +38271,11 @@ snapshots: dependencies: '@tauri-apps/api': 1.6.0 + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + terminal-link@5.0.0: dependencies: ansi-escapes: 7.2.0 @@ -32875,7 +38311,12 @@ snapshots: acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 - optional: true + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 test-exclude@7.0.1: dependencies: @@ -32899,6 +38340,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throat@5.0.0: {} + through@2.3.8: {} thunky@1.1.0: {} @@ -32953,6 +38396,8 @@ snapshots: tmp@0.2.5: {} + tmpl@1.0.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -32969,6 +38414,8 @@ snapshots: toml@3.0.0: {} + toqr@0.1.1: {} + totalist@3.0.1: {} tough-cookie@5.1.2: @@ -33047,7 +38494,29 @@ snapshots: '@swc/wasm': 1.15.5 optional: true - ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@20.17.43)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.43 + acorn: 8.16.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + '@swc/wasm': 1.15.5 + optional: true + + ts-node@10.9.2(@swc/core@1.15.5(@swc/helpers@0.5.17))(@swc/wasm@1.15.5)(@types/node@22.15.17)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -33061,7 +38530,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.8.3 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: @@ -33073,6 +38542,10 @@ snapshots: optionalDependencies: typescript: 5.8.3 + tsconfck@3.1.5(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -33111,6 +38584,31 @@ snapshots: - supports-color - vue-tsc + tsdown@0.15.6(typescript@5.9.3): + dependencies: + ansis: 4.2.0 + cac: 6.7.14 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 8.0.2 + empathic: 2.0.0 + hookable: 5.5.3 + rolldown: 1.0.1 + rolldown-plugin-dts: 0.16.11(rolldown@1.0.1)(typescript@5.9.3) + semver: 7.7.2 + tinyexec: 1.0.1 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + unconfig: 7.3.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - supports-color + - vue-tsc + tslib@1.14.1: {} tslib@2.6.2: {} @@ -33146,10 +38644,39 @@ snapshots: - tsx - yaml - tsutils@3.21.0(typescript@5.8.3): + tsup@8.5.0(@swc/core@1.15.5(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.3)(typescript@5.9.3)(yaml@2.8.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.12) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.25.12 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.3)(yaml@2.8.1) + resolve-from: 5.0.0 + rollup: 4.40.2 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.15.5(@swc/helpers@0.5.17) + postcss: 8.5.3 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsutils@3.21.0(typescript@5.9.3): dependencies: tslib: 1.14.1 - typescript: 5.8.3 + typescript: 5.9.3 tsyringe@4.10.0: dependencies: @@ -33198,10 +38725,14 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} + type-fest@0.7.1: {} + type-fest@4.41.0: {} type-is@1.6.18: @@ -33263,6 +38794,8 @@ snapshots: typescript@5.8.3: {} + typescript@5.9.3: {} + ua-parser-js@1.0.41: {} ufo@1.6.1: {} @@ -33304,7 +38837,7 @@ snapshots: dependencies: acorn: 8.14.1 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 unplugin: 2.3.2 unctx@2.5.0: @@ -33366,6 +38899,17 @@ snapshots: pathe: 2.0.3 ufo: 1.6.1 + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -33388,10 +38932,10 @@ snapshots: estree-walker: 3.0.3 fast-glob: 3.3.3 local-pkg: 1.1.1 - magic-string: 0.30.17 + magic-string: 0.30.21 mlly: 1.7.4 pathe: 2.0.3 - picomatch: 4.0.2 + picomatch: 4.0.3 pkg-types: 1.3.1 scule: 1.3.0 strip-literal: 2.1.1 @@ -33405,7 +38949,7 @@ snapshots: escape-string-regexp: 5.0.0 estree-walker: 3.0.3 local-pkg: 1.1.1 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 pathe: 2.0.3 picomatch: 4.0.3 @@ -33505,11 +39049,11 @@ snapshots: transitivePeerDependencies: - rollup - unplugin-fonts@1.3.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + unplugin-fonts@1.3.1(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: fast-glob: 3.3.3 unplugin: 2.0.0-beta.1 - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) unplugin-icons@0.19.3: dependencies: @@ -33612,7 +39156,7 @@ snapshots: unwasm@0.3.9: dependencies: knitwork: 1.2.0 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 pathe: 1.1.2 pkg-types: 1.3.1 @@ -33640,6 +39184,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uqr@0.1.2: {} uri-js@4.4.1: @@ -33657,6 +39207,13 @@ snapshots: urlpattern-polyfill@8.0.2: {} + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): dependencies: react: 19.2.4 @@ -33669,12 +39226,24 @@ snapshots: dequal: 2.0.3 react: 19.2.4 + use-latest-callback@0.2.6(react@19.2.0): + dependencies: + react: 19.2.0 + use-resize-observer@9.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@juggle/resize-observer': 3.4.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): dependencies: detect-node-es: 1.1.0 @@ -33683,6 +39252,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sync-external-store@1.5.0(react@19.2.0): + dependencies: + react: 19.2.0 + use-sync-external-store@1.5.0(react@19.2.4): dependencies: react: 19.2.4 @@ -33703,6 +39276,8 @@ snapshots: uuid@11.1.0: {} + uuid@7.0.3: {} + uuid@8.0.0: {} uuid@8.3.2: {} @@ -33718,6 +39293,11 @@ snapshots: optionalDependencies: typescript: 5.8.3 + valibot@1.0.0-rc.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + optional: true + validate-html-nesting@1.2.2: {} validate-npm-package-license@3.0.4: @@ -33729,6 +39309,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -33766,7 +39355,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): + vinxi@0.5.6(@planetscale/database@1.19.0)(@types/node@22.15.17)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(encoding@0.1.13)(ioredis@5.6.1)(jiti@2.6.1)(lightningcss@1.32.0)(mysql2@3.15.2)(rolldown@1.0.1)(terser@5.44.0)(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@babel/core': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.1) @@ -33800,7 +39389,7 @@ snapshots: unctx: 2.4.1 unenv: 1.10.0 unstorage: 1.16.0(@planetscale/database@1.19.0)(db0@0.3.2(drizzle-orm@0.44.6(@cloudflare/workers-types@4.20250507.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.14)(mysql2@3.15.2))(mysql2@3.15.2))(ioredis@5.6.1) - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) zod: 3.25.76 transitivePeerDependencies: - '@azure/app-configuration' @@ -33844,15 +39433,34 @@ snapshots: - xml2js - yaml - vite-node@2.1.9(@types/node@22.15.17)(terser@5.44.0): + vite-node@2.1.9(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.19(@types/node@22.15.17)(terser@5.44.0) + vite: 5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-node@3.2.4(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' + - jiti - less - lightningcss - sass @@ -33861,14 +39469,16 @@ snapshots: - sugarss - supports-color - terser + - tsx + - yaml - vite-node@3.2.4(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): + vite-node@3.2.4(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -33883,7 +39493,7 @@ snapshots: - tsx - yaml - vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vite-plugin-solid@2.11.6(@testing-library/jest-dom@6.5.0)(solid-js@1.9.6)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: '@babel/core': 7.27.1 '@types/babel__core': 7.20.5 @@ -33891,51 +39501,62 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.6 solid-refresh: 0.6.3(solid-js@1.9.6) - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - vitefu: 1.0.6(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + vitefu: 1.0.6(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) optionalDependencies: '@testing-library/jest-dom': 6.5.0 transitivePeerDependencies: - supports-color - vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vite-plugin-top-level-await@1.6.0(@swc/helpers@0.5.17)(rollup@4.40.2)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: '@rollup/plugin-virtual': 3.0.2(rollup@4.40.2) '@swc/core': 1.15.5(@swc/helpers@0.5.17) '@swc/wasm': 1.15.5 uuid: 10.0.0 - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - '@swc/helpers' - rollup - vite-plugin-wasm@3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vite-plugin-wasm@3.5.0(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) - vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vite-tsconfig-paths@4.3.2(typescript@5.8.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + - typescript + + vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): + dependencies: + debug: 4.4.0 + globrex: 0.1.2 + tsconfck: 3.1.5(typescript@5.9.3) + optionalDependencies: + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): dependencies: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@5.4.19(@types/node@22.15.17)(terser@5.44.0): + vite@5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0): dependencies: esbuild: 0.21.5 postcss: 8.5.3 @@ -33943,9 +39564,10 @@ snapshots: optionalDependencies: '@types/node': 22.15.17 fsevents: 2.3.3 + lightningcss: 1.32.0 terser: 5.44.0 - vite@6.1.4(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): + vite@6.1.4(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.24.2 postcss: 8.5.3 @@ -33954,10 +39576,11 @@ snapshots: '@types/node': 22.15.17 fsevents: 2.3.3 jiti: 2.6.1 + lightningcss: 1.32.0 terser: 5.44.0 yaml: 2.8.1 - vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): + vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.25.4 fdir: 6.4.4(picomatch@4.0.2) @@ -33969,10 +39592,11 @@ snapshots: '@types/node': 20.17.43 fsevents: 2.3.3 jiti: 2.6.1 + lightningcss: 1.32.0 terser: 5.44.0 yaml: 2.8.1 - vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1): + vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): dependencies: esbuild: 0.25.4 fdir: 6.4.4(picomatch@4.0.2) @@ -33984,17 +39608,18 @@ snapshots: '@types/node': 22.15.17 fsevents: 2.3.3 jiti: 2.6.1 + lightningcss: 1.32.0 terser: 5.44.0 yaml: 2.8.1 - vitefu@1.0.6(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)): + vitefu@1.0.6(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)): optionalDependencies: - vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) - vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(terser@5.44.0): + vitest@2.1.9(@types/node@22.15.17)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.17)(terser@5.44.0)) + '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -34010,8 +39635,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.19(@types/node@22.15.17)(terser@5.44.0) - vite-node: 2.1.9(@types/node@22.15.17)(terser@5.44.0) + vite: 5.4.19(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0) + vite-node: 2.1.9(@types/node@22.15.17)(lightningcss@1.32.0)(terser@5.44.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.15.17 @@ -34027,11 +39652,11 @@ snapshots: - supports-color - terser - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.17.43)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -34040,7 +39665,7 @@ snapshots: chai: 5.2.0 debug: 4.4.3(supports-color@8.1.1) expect-type: 1.2.1 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 picomatch: 4.0.3 std-env: 3.9.0 @@ -34049,8 +39674,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@20.17.43)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) + vite: 6.3.5(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@20.17.43)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -34071,12 +39696,64 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.17)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.0 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.2.1 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.3.5(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@22.15.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.15.17 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vlq@1.0.1: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 walk-up-path@3.0.1: {} + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warn-once@0.1.1: {} + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -34085,7 +39762,6 @@ snapshots: wcwidth@1.0.1: dependencies: defaults: 1.0.4 - optional: true web-namespaces@2.0.1: {} @@ -34126,7 +39802,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.26.3 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 es-module-lexer: 1.7.0 @@ -34222,8 +39898,12 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + whatwg-url-minimum@0.1.2: {} + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -34346,14 +40026,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20250408.0 '@cloudflare/workerd-windows-64': 1.20250408.0 - workflow@4.2.0-beta.73(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3(@swc/helpers@0.5.17))(chokidar@5.0.0))(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(magicast@0.3.5)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.8.3): + workflow@4.2.0-beta.73(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3(@swc/helpers@0.5.17))(chokidar@5.0.0))(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/helpers@0.5.17)(magicast@0.3.5)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.8.3): dependencies: '@workflow/astro': 4.0.0-beta.47(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17) '@workflow/cli': 4.2.0-beta.73(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17) '@workflow/core': 4.2.0-beta.73(@opentelemetry/api@1.9.0) '@workflow/errors': 4.1.0-beta.19 '@workflow/nest': 0.0.0-beta.22(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3(@swc/helpers@0.5.17))(chokidar@5.0.0))(@swc/core@1.15.3(@swc/helpers@0.5.17))(@swc/helpers@0.5.17) - '@workflow/next': 4.0.1-beta.69(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17)(next@16.2.1(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@workflow/next': 4.0.1-beta.69(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17)(next@16.2.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@workflow/nitro': 4.0.1-beta.68(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17) '@workflow/nuxt': 4.0.1-beta.57(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17)(magicast@0.3.5) '@workflow/rollup': 4.0.0-beta.30(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.17) @@ -34434,6 +40114,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 @@ -34444,6 +40129,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@7.5.10: {} + ws@8.17.1: {} ws@8.18.0: {} @@ -34456,6 +40143,11 @@ snapshots: dependencies: is-wsl: 3.1.0 + xcode@3.0.1: + dependencies: + simple-plist: 1.3.1 + uuid: 7.0.3 + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 @@ -34466,6 +40158,11 @@ snapshots: xml-name-validator@5.0.0: {} + xml2js@0.6.0: + dependencies: + sax: 1.2.1 + xmlbuilder: 11.0.1 + xml2js@0.6.2: dependencies: sax: 1.2.1 @@ -34473,6 +40170,8 @@ snapshots: xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} y18n@5.0.8: {} From b879347836e80312b60e8f6ac131592af286b3d5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 19 May 2026 22:41:08 +0100 Subject: [PATCH 20/20] chore(web): refresh workflow step manifest --- .../.well-known/workflow/v1/manifest.json | 579 +++++++++--------- 1 file changed, 291 insertions(+), 288 deletions(-) diff --git a/apps/web/public/.well-known/workflow/v1/manifest.json b/apps/web/public/.well-known/workflow/v1/manifest.json index 9548c93842..fda9d32654 100644 --- a/apps/web/public/.well-known/workflow/v1/manifest.json +++ b/apps/web/public/.well-known/workflow/v1/manifest.json @@ -1,289 +1,292 @@ { - "version": "1.0.0", - "steps": { - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/internal/builtins.js": { - "__builtin_response_array_buffer": { - "stepId": "__builtin_response_array_buffer" - }, - "__builtin_response_json": { - "stepId": "__builtin_response_json" - }, - "__builtin_response_text": { - "stepId": "__builtin_response_text" - } - }, - "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_2a6f07c63175c34d8d104665b34a32ad/node_modules/workflow/dist/stdlib.js": { - "fetch": { - "stepId": "step//workflow@4.2.0-beta.73//fetch" - } - }, - "workflows/process-video.ts": { - "cleanupRawUpload": { - "stepId": "step//./workflows/process-video//cleanupRawUpload" - }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/process-video//processVideoOnMediaServer" - }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/process-video//saveMetadataAndComplete" - }, - "setProcessingError": { - "stepId": "step//./workflows/process-video//setProcessingError" - }, - "validateProcessingRequest": { - "stepId": "step//./workflows/process-video//validateProcessingRequest" - } - }, - "workflows/edit-video.ts": { - "clearEditProcessingState": { - "stepId": "step//./workflows/edit-video//clearEditProcessingState" - }, - "invalidateEditedVideoCache": { - "stepId": "step//./workflows/edit-video//invalidateEditedVideoCache" - }, - "queueTranscriptionRegeneration": { - "stepId": "step//./workflows/edit-video//queueTranscriptionRegeneration" - }, - "renderVideoEditOnMediaServer": { - "stepId": "step//./workflows/edit-video//renderVideoEditOnMediaServer" - }, - "saveEditResultAndComplete": { - "stepId": "step//./workflows/edit-video//saveEditResultAndComplete" - }, - "validateEditRequest": { - "stepId": "step//./workflows/edit-video//validateEditRequest" - } - }, - "workflows/import-loom-video.ts": { - "downloadLoomToS3": { - "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" - }, - "processVideoOnMediaServer": { - "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" - }, - "saveMetadataAndComplete": { - "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" - }, - "setProcessingError": { - "stepId": "step//./workflows/import-loom-video//setProcessingError" - } - }, - "workflows/transcribe.ts": { - "_enhanceAndSaveAudio": { - "stepId": "step//./workflows/transcribe//_enhanceAndSaveAudio" - }, - "_markEnhancedAudioProcessing": { - "stepId": "step//./workflows/transcribe//_markEnhancedAudioProcessing" - }, - "cleanupTempAudio": { - "stepId": "step//./workflows/transcribe//cleanupTempAudio" - }, - "extractAudio": { - "stepId": "step//./workflows/transcribe//extractAudio" - }, - "markNoAudio": { - "stepId": "step//./workflows/transcribe//markNoAudio" - }, - "markSkipped": { - "stepId": "step//./workflows/transcribe//markSkipped" - }, - "queueAiGeneration": { - "stepId": "step//./workflows/transcribe//queueAiGeneration" - }, - "saveTranscription": { - "stepId": "step//./workflows/transcribe//saveTranscription" - }, - "transcribeWithDeepgram": { - "stepId": "step//./workflows/transcribe//transcribeWithDeepgram" - }, - "validateVideo": { - "stepId": "step//./workflows/transcribe//validateVideo" - } - }, - "workflows/generate-ai.ts": { - "fetchTranscript": { - "stepId": "step//./workflows/generate-ai//fetchTranscript" - }, - "generateWithAi": { - "stepId": "step//./workflows/generate-ai//generateWithAi" - }, - "markSkipped": { - "stepId": "step//./workflows/generate-ai//markSkipped" - }, - "saveResults": { - "stepId": "step//./workflows/generate-ai//saveResults" - }, - "validateAndSetProcessing": { - "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" - } - } - }, - "workflows": { - "workflows/process-video.ts": { - "processVideoWorkflow": { - "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", - "graph": { - "nodes": [ - { - "id": "start", - "type": "workflowStart", - "data": { - "label": "Start: processVideoWorkflow", - "nodeKind": "workflow_start" - } - }, - { - "id": "end", - "type": "workflowEnd", - "data": { - "label": "Return", - "nodeKind": "workflow_end" - } - } - ], - "edges": [ - { - "id": "e_start_end", - "source": "start", - "target": "end", - "type": "default" - } - ] - } - } - }, - "workflows/edit-video.ts": { - "editVideoWorkflow": { - "workflowId": "workflow//./workflows/edit-video//editVideoWorkflow", - "graph": { - "nodes": [ - { - "id": "start", - "type": "workflowStart", - "data": { - "label": "Start: editVideoWorkflow", - "nodeKind": "workflow_start" - } - }, - { - "id": "end", - "type": "workflowEnd", - "data": { - "label": "Return", - "nodeKind": "workflow_end" - } - } - ], - "edges": [ - { - "id": "e_start_end", - "source": "start", - "target": "end", - "type": "default" - } - ] - } - } - }, - "workflows/import-loom-video.ts": { - "importLoomVideoWorkflow": { - "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", - "graph": { - "nodes": [ - { - "id": "start", - "type": "workflowStart", - "data": { - "label": "Start: importLoomVideoWorkflow", - "nodeKind": "workflow_start" - } - }, - { - "id": "end", - "type": "workflowEnd", - "data": { - "label": "Return", - "nodeKind": "workflow_end" - } - } - ], - "edges": [ - { - "id": "e_start_end", - "source": "start", - "target": "end", - "type": "default" - } - ] - } - } - }, - "workflows/transcribe.ts": { - "transcribeVideoWorkflow": { - "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", - "graph": { - "nodes": [ - { - "id": "start", - "type": "workflowStart", - "data": { - "label": "Start: transcribeVideoWorkflow", - "nodeKind": "workflow_start" - } - }, - { - "id": "end", - "type": "workflowEnd", - "data": { - "label": "Return", - "nodeKind": "workflow_end" - } - } - ], - "edges": [ - { - "id": "e_start_end", - "source": "start", - "target": "end", - "type": "default" - } - ] - } - } - }, - "workflows/generate-ai.ts": { - "generateAiWorkflow": { - "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", - "graph": { - "nodes": [ - { - "id": "start", - "type": "workflowStart", - "data": { - "label": "Start: generateAiWorkflow", - "nodeKind": "workflow_start" - } - }, - { - "id": "end", - "type": "workflowEnd", - "data": { - "label": "Return", - "nodeKind": "workflow_end" - } - } - ], - "edges": [ - { - "id": "e_start_end", - "source": "start", - "target": "end", - "type": "default" - } - ] - } - } - } - }, - "classes": {} -} + "version": "1.0.0", + "steps": { + "workflows/generate-ai.ts": { + "fetchTranscript": { + "stepId": "step//./workflows/generate-ai//fetchTranscript" + }, + "generateWithAi": { + "stepId": "step//./workflows/generate-ai//generateWithAi" + }, + "markSkipped": { + "stepId": "step//./workflows/generate-ai//markSkipped" + }, + "saveResults": { + "stepId": "step//./workflows/generate-ai//saveResults" + }, + "validateAndSetProcessing": { + "stepId": "step//./workflows/generate-ai//validateAndSetProcessing" + } + }, + "workflows/process-video.ts": { + "cleanupRawUpload": { + "stepId": "step//./workflows/process-video//cleanupRawUpload" + }, + "processVideoOnMediaServer": { + "stepId": "step//./workflows/process-video//processVideoOnMediaServer" + }, + "saveMetadataAndComplete": { + "stepId": "step//./workflows/process-video//saveMetadataAndComplete" + }, + "setProcessingError": { + "stepId": "step//./workflows/process-video//setProcessingError" + }, + "validateProcessingRequest": { + "stepId": "step//./workflows/process-video//validateProcessingRequest" + } + }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_a4704d8c92ead1c54153943c93b08dfc/node_modules/workflow/dist/stdlib.js": { + "fetch": { + "stepId": "step//workflow@4.2.0-beta.73//fetch" + } + }, + "workflows/import-loom-video.ts": { + "downloadLoomToS3": { + "stepId": "step//./workflows/import-loom-video//downloadLoomToS3" + }, + "processVideoOnMediaServer": { + "stepId": "step//./workflows/import-loom-video//processVideoOnMediaServer" + }, + "saveMetadataAndComplete": { + "stepId": "step//./workflows/import-loom-video//saveMetadataAndComplete" + }, + "setProcessingError": { + "stepId": "step//./workflows/import-loom-video//setProcessingError" + } + }, + "node_modules/.pnpm/workflow@4.2.0-beta.73_@nestjs+common@11.1.17_reflect-metadata@0.2.2_rxjs@7.8.2__@nestj_a4704d8c92ead1c54153943c93b08dfc/node_modules/workflow/dist/internal/builtins.js": { + "__builtin_response_array_buffer": { + "stepId": "__builtin_response_array_buffer" + }, + "__builtin_response_json": { + "stepId": "__builtin_response_json" + }, + "__builtin_response_text": { + "stepId": "__builtin_response_text" + } + }, + "workflows/edit-video.ts": { + "clearEditProcessingState": { + "stepId": "step//./workflows/edit-video//clearEditProcessingState" + }, + "invalidateEditedVideoCache": { + "stepId": "step//./workflows/edit-video//invalidateEditedVideoCache" + }, + "queueTranscriptionRegeneration": { + "stepId": "step//./workflows/edit-video//queueTranscriptionRegeneration" + }, + "renderVideoEditOnMediaServer": { + "stepId": "step//./workflows/edit-video//renderVideoEditOnMediaServer" + }, + "saveEditResultAndComplete": { + "stepId": "step//./workflows/edit-video//saveEditResultAndComplete" + }, + "validateEditRequest": { + "stepId": "step//./workflows/edit-video//validateEditRequest" + } + }, + "workflows/transcribe.ts": { + "_enhanceAndSaveAudio": { + "stepId": "step//./workflows/transcribe//_enhanceAndSaveAudio" + }, + "_markEnhancedAudioProcessing": { + "stepId": "step//./workflows/transcribe//_markEnhancedAudioProcessing" + }, + "cleanupTempAudio": { + "stepId": "step//./workflows/transcribe//cleanupTempAudio" + }, + "extractAudio": { + "stepId": "step//./workflows/transcribe//extractAudio" + }, + "markError": { + "stepId": "step//./workflows/transcribe//markError" + }, + "markNoAudio": { + "stepId": "step//./workflows/transcribe//markNoAudio" + }, + "markSkipped": { + "stepId": "step//./workflows/transcribe//markSkipped" + }, + "queueAiGeneration": { + "stepId": "step//./workflows/transcribe//queueAiGeneration" + }, + "saveTranscription": { + "stepId": "step//./workflows/transcribe//saveTranscription" + }, + "transcribeWithDeepgram": { + "stepId": "step//./workflows/transcribe//transcribeWithDeepgram" + }, + "validateVideo": { + "stepId": "step//./workflows/transcribe//validateVideo" + } + } + }, + "workflows": { + "workflows/generate-ai.ts": { + "generateAiWorkflow": { + "workflowId": "workflow//./workflows/generate-ai//generateAiWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: generateAiWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } + }, + "workflows/process-video.ts": { + "processVideoWorkflow": { + "workflowId": "workflow//./workflows/process-video//processVideoWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: processVideoWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } + }, + "workflows/import-loom-video.ts": { + "importLoomVideoWorkflow": { + "workflowId": "workflow//./workflows/import-loom-video//importLoomVideoWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: importLoomVideoWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } + }, + "workflows/edit-video.ts": { + "editVideoWorkflow": { + "workflowId": "workflow//./workflows/edit-video//editVideoWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: editVideoWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } + }, + "workflows/transcribe.ts": { + "transcribeVideoWorkflow": { + "workflowId": "workflow//./workflows/transcribe//transcribeVideoWorkflow", + "graph": { + "nodes": [ + { + "id": "start", + "type": "workflowStart", + "data": { + "label": "Start: transcribeVideoWorkflow", + "nodeKind": "workflow_start" + } + }, + { + "id": "end", + "type": "workflowEnd", + "data": { + "label": "Return", + "nodeKind": "workflow_end" + } + } + ], + "edges": [ + { + "id": "e_start_end", + "source": "start", + "target": "end", + "type": "default" + } + ] + } + } + } + }, + "classes": {} +} \ No newline at end of file