diff --git a/.devcontainer/vonage_video_ios_app-theme-customization/devcontainer.json b/.devcontainer/vonage_video_ios_app-theme-customization/devcontainer.json new file mode 100644 index 00000000..dc9e3175 --- /dev/null +++ b/.devcontainer/vonage_video_ios_app-theme-customization/devcontainer.json @@ -0,0 +1,61 @@ +{ + "name": "Tutorial: vonage_video_ios_app-theme-customization", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm", + "workspaceFolder": "/workspaces/tutorial-interactive_tutorials/tutorials/vonage_video_ios_app-theme-customization", + "waitFor": "onCreateCommand", + "updateContentCommand": "npm install", + "postCreateCommand": "", + "postAttachCommand": "nohup ./node_modules/.bin/http-server steps -p 1234 --cors -c-1 > /dev/null 2>&1 & cd project && (sleep 4 && echo -e \"\\n\\n🚀 APPLICATION READY:\\nhttps://${CODESPACE_NAME}-3000.app.github.dev\\n\\n\" &) && (nohup sh -c \"sleep 5 && gh codespace ports visibility 3000:public -c $CODESPACE_NAME\" > /dev/null 2>&1 &) && npm start", + "forwardPorts": [ + "1234", + "3000" + ], + "portsAttributes": { + "1234": { + "label": "Tutorial Guide", + "onAutoForward": "openPreview" + }, + "3000": { + "label": "My Backend Service", + "onAutoForward": "notify", + "visibility": "public" + } + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [], + "settings": { + "editor.formatOnSave": true, + "files.exclude": { + "node_modules": true, + "dist": true, + "steps": true, + ".devcontainer": true, + ".vscode": true, + "package.json": true, + "package-lock.json": true, + "tutorial-config.json": true, + "tsconfig.json": true, + "astro.config.mjs": true, + ".git": true, + ".DS_Store": true, + "project/setup-project.js": true, + "vonage-toolbar": true, + "src": true, + "markdoc.config.mjs": true, + ".astro": true, + "README.md": true, + "public": true, + "download-toolbar.js": true + }, + "terminal.integrated.cwd": "${workspaceFolder}/project" + } + }, + "codespaces": { + "openFiles": [] + } + } +} \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json b/sources/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json new file mode 100644 index 00000000..15dc3fb5 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json @@ -0,0 +1,650 @@ +{ + "$ref": "#/definitions/docs", + "definitions": { + "docs": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "editUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "head": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "title", + "base", + "link", + "style", + "meta", + "script", + "noscript", + "template" + ] + }, + "attrs": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "not": {} + } + ] + } + }, + "content": { + "type": "string" + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + "default": [] + }, + "tableOfContents": { + "anyOf": [ + { + "type": "object", + "properties": { + "minHeadingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 2 + }, + "maxHeadingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 3 + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ], + "default": { + "minHeadingLevel": 2, + "maxHeadingLevel": 3 + } + }, + "template": { + "type": "string", + "enum": [ + "doc", + "splash" + ], + "default": "doc" + }, + "hero": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "object", + "properties": { + "alt": { + "type": "string", + "default": "" + }, + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "alt": { + "type": "string", + "default": "" + }, + "dark": { + "type": "string" + }, + "light": { + "type": "string" + } + }, + "required": [ + "dark", + "light" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "html": { + "type": "string" + } + }, + "required": [ + "html" + ], + "additionalProperties": false + } + ] + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "link": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "primary", + "secondary", + "minimal" + ], + "default": "primary" + }, + "icon": { + "anyOf": [ + { + "type": "string", + "enum": [ + "up-caret", + "down-caret", + "right-caret", + "left-caret", + "up-arrow", + "down-arrow", + "right-arrow", + "left-arrow", + "bars", + "translate", + "pencil", + "pen", + "document", + "add-document", + "setting", + "external", + "download", + "cloud-download", + "moon", + "sun", + "laptop", + "open-book", + "information", + "magnifier", + "forward-slash", + "close", + "error", + "warning", + "approve-check-circle", + "approve-check", + "rocket", + "star", + "puzzle", + "list-format", + "random", + "comment", + "comment-alt", + "heart", + "github", + "gitlab", + "bitbucket", + "codePen", + "farcaster", + "discord", + "gitter", + "twitter", + "x.com", + "mastodon", + "codeberg", + "youtube", + "threads", + "linkedin", + "twitch", + "azureDevOps", + "microsoftTeams", + "instagram", + "stackOverflow", + "telegram", + "rss", + "facebook", + "email", + "phone", + "reddit", + "patreon", + "signal", + "slack", + "matrix", + "hackerOne", + "openCollective", + "blueSky", + "discourse", + "zulip", + "pinterest", + "tiktok", + "astro", + "alpine", + "pnpm", + "biome", + "bun", + "mdx", + "apple", + "linux", + "homebrew", + "nix", + "starlight", + "pkl", + "node", + "cloudflare", + "vercel", + "netlify", + "deno", + "jsr", + "nostr", + "backstage", + "confluence", + "jira", + "storybook", + "vscode", + "jetbrains", + "zed", + "vim", + "figma", + "sketch", + "npm", + "sourcehut", + "substack", + "chrome", + "edge", + "firefox", + "safari", + "seti:folder", + "seti:bsl", + "seti:mdo", + "seti:salesforce", + "seti:asm", + "seti:bicep", + "seti:bazel", + "seti:c", + "seti:c-sharp", + "seti:html", + "seti:cpp", + "seti:clojure", + "seti:coldfusion", + "seti:config", + "seti:crystal", + "seti:crystal_embedded", + "seti:json", + "seti:css", + "seti:csv", + "seti:xls", + "seti:cu", + "seti:cake", + "seti:cake_php", + "seti:d", + "seti:word", + "seti:elixir", + "seti:elixir_script", + "seti:hex", + "seti:elm", + "seti:favicon", + "seti:f-sharp", + "seti:git", + "seti:go", + "seti:godot", + "seti:gradle", + "seti:grails", + "seti:graphql", + "seti:hacklang", + "seti:haml", + "seti:mustache", + "seti:haskell", + "seti:haxe", + "seti:jade", + "seti:java", + "seti:javascript", + "seti:jinja", + "seti:julia", + "seti:karma", + "seti:kotlin", + "seti:dart", + "seti:liquid", + "seti:livescript", + "seti:lua", + "seti:markdown", + "seti:argdown", + "seti:info", + "seti:clock", + "seti:maven", + "seti:nim", + "seti:github", + "seti:notebook", + "seti:nunjucks", + "seti:npm", + "seti:ocaml", + "seti:odata", + "seti:perl", + "seti:php", + "seti:pipeline", + "seti:pddl", + "seti:plan", + "seti:happenings", + "seti:powershell", + "seti:prisma", + "seti:pug", + "seti:puppet", + "seti:purescript", + "seti:python", + "seti:react", + "seti:rescript", + "seti:R", + "seti:ruby", + "seti:rust", + "seti:sass", + "seti:spring", + "seti:slim", + "seti:smarty", + "seti:sbt", + "seti:scala", + "seti:ethereum", + "seti:stylus", + "seti:svelte", + "seti:swift", + "seti:db", + "seti:terraform", + "seti:tex", + "seti:default", + "seti:twig", + "seti:typescript", + "seti:tsconfig", + "seti:vala", + "seti:vite", + "seti:vue", + "seti:wasm", + "seti:wat", + "seti:xml", + "seti:yml", + "seti:prolog", + "seti:zig", + "seti:zip", + "seti:wgt", + "seti:illustrator", + "seti:photoshop", + "seti:pdf", + "seti:font", + "seti:image", + "seti:svg", + "seti:sublime", + "seti:code-search", + "seti:shell", + "seti:video", + "seti:audio", + "seti:windows", + "seti:jenkins", + "seti:babel", + "seti:bower", + "seti:docker", + "seti:code-climate", + "seti:eslint", + "seti:firebase", + "seti:firefox", + "seti:gitlab", + "seti:grunt", + "seti:gulp", + "seti:ionic", + "seti:platformio", + "seti:rollup", + "seti:stylelint", + "seti:yarn", + "seti:webpack", + "seti:lock", + "seti:license", + "seti:makefile", + "seti:heroku", + "seti:todo", + "seti:ignored" + ] + }, + { + "type": "string", + "pattern": "^\\ import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Findex.mdx&astroContentModuleFlag=true")], +["src/content/docs/reference.mdoc", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Freference.mdoc&astroContentModuleFlag=true")]]); + \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.astro/content.d.ts b/sources/vonage_video_ios_app-theme-customization/.astro/content.d.ts new file mode 100644 index 00000000..ba5cbfb8 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.astro/content.d.ts @@ -0,0 +1,227 @@ +declare module 'astro:content' { + interface Render { + '.mdoc': Promise<{ + Content(props: Record): import('astro').MarkdownInstance<{}>['Content']; + headings: import('astro').MarkdownHeading[]; + }>; + } +} + +declare module 'astro:content' { + interface Render { + '.mdx': Promise<{ + Content: import('astro').MDXContent; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + components: import('astro').MDXInstance<{}>['components']; + }>; + } +} + +declare module 'astro:content' { + export interface RenderResult { + Content: import('astro/runtime/server/index.js').AstroComponentFactory; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + } + interface Render { + '.md': Promise; + } + + export interface RenderedContent { + html: string; + metadata?: { + imagePaths: Array; + [key: string]: unknown; + }; + } +} + +declare module 'astro:content' { + type Flatten = T extends { [K: string]: infer U } ? U : never; + + export type CollectionKey = keyof AnyEntryMap; + export type CollectionEntry = Flatten; + + export type ContentCollectionKey = keyof ContentEntryMap; + export type DataCollectionKey = keyof DataEntryMap; + + type AllValuesOf = T extends any ? T[keyof T] : never; + type ValidContentEntrySlug = AllValuesOf< + ContentEntryMap[C] + >['slug']; + + export type ReferenceDataEntry< + C extends CollectionKey, + E extends keyof DataEntryMap[C] = string, + > = { + collection: C; + id: E; + }; + export type ReferenceContentEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}) = string, + > = { + collection: C; + slug: E; + }; + export type ReferenceLiveEntry = { + collection: C; + id: string; + }; + + /** @deprecated Use `getEntry` instead. */ + export function getEntryBySlug< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + // Note that this has to accept a regular string too, for SSR + entrySlug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + + /** @deprecated Use `getEntry` instead. */ + export function getDataEntryById( + collection: C, + entryId: E, + ): Promise>; + + export function getCollection>( + collection: C, + filter?: (entry: CollectionEntry) => entry is E, + ): Promise; + export function getCollection( + collection: C, + filter?: (entry: CollectionEntry) => unknown, + ): Promise[]>; + + export function getLiveCollection( + collection: C, + filter?: LiveLoaderCollectionFilterType, + ): Promise< + import('astro').LiveDataCollectionResult, LiveLoaderErrorType> + >; + + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + entry: ReferenceContentEntry, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + entry: ReferenceDataEntry, + ): E extends keyof DataEntryMap[C] + ? Promise + : Promise | undefined>; + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + slug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + collection: C, + id: E, + ): E extends keyof DataEntryMap[C] + ? string extends keyof DataEntryMap[C] + ? Promise | undefined + : Promise + : Promise | undefined>; + export function getLiveEntry( + collection: C, + filter: string | LiveLoaderEntryFilterType, + ): Promise, LiveLoaderErrorType>>; + + /** Resolve an array of entry references from the same collection */ + export function getEntries( + entries: ReferenceContentEntry>[], + ): Promise[]>; + export function getEntries( + entries: ReferenceDataEntry[], + ): Promise[]>; + + export function render( + entry: AnyEntryMap[C][string], + ): Promise; + + export function reference( + collection: C, + ): import('astro/zod').ZodEffects< + import('astro/zod').ZodString, + C extends keyof ContentEntryMap + ? ReferenceContentEntry> + : ReferenceDataEntry + >; + // Allow generic `string` to avoid excessive type errors in the config + // if `dev` is not running to update as you edit. + // Invalid collection names will be caught at build time. + export function reference( + collection: C, + ): import('astro/zod').ZodEffects; + + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; + type InferEntrySchema = import('astro/zod').infer< + ReturnTypeOrOriginal['schema']> + >; + + type ContentEntryMap = { + + }; + + type DataEntryMap = { + "docs": Record; + rendered?: RenderedContent; + filePath?: string; +}>; + + }; + + type AnyEntryMap = ContentEntryMap & DataEntryMap; + + type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< + infer TData, + infer TEntryFilter, + infer TCollectionFilter, + infer TError + > + ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; + type ExtractDataType = ExtractLoaderTypes['data']; + type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; + type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; + type ExtractErrorType = ExtractLoaderTypes['error']; + + type LiveLoaderDataType = + LiveContentConfig['collections'][C]['schema'] extends undefined + ? ExtractDataType + : import('astro/zod').infer< + Exclude + >; + type LiveLoaderEntryFilterType = + ExtractEntryFilterType; + type LiveLoaderCollectionFilterType = + ExtractCollectionFilterType; + type LiveLoaderErrorType = ExtractErrorType< + LiveContentConfig['collections'][C]['loader'] + >; + + export type ContentConfig = typeof import("../src/content.config.js"); + export type LiveContentConfig = never; +} diff --git a/sources/vonage_video_ios_app-theme-customization/.astro/data-store.json b/sources/vonage_video_ios_app-theme-customization/.astro/data-store.json new file mode 100644 index 00000000..27b0ccc2 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.astro/data-store.json @@ -0,0 +1 @@ +[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.18.1","content-config-digest","a30a942a2ceda2e1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"where\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":false,\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[null,null,null],\"rehypePlugins\":[[null,{\"experimentalHeadingIdCompat\":false}],null,[null,{\"themes\":[{\"name\":\"Night Owl No Italics\",\"type\":\"dark\",\"colors\":{\"focusBorder\":\"#122d42\",\"foreground\":\"#d6deeb\",\"disabledForeground\":\"#cccccc80\",\"descriptionForeground\":\"#d6deebb3\",\"errorForeground\":\"#ef5350\",\"icon.foreground\":\"#c5c5c5\",\"contrastActiveBorder\":null,\"contrastBorder\":\"#122d42\",\"textBlockQuote.background\":\"#7f7f7f1a\",\"textBlockQuote.border\":\"#007acc80\",\"textCodeBlock.background\":\"#4f4f4f\",\"textLink.activeForeground\":\"#3794ff\",\"textLink.foreground\":\"#3794ff\",\"textPreformat.foreground\":\"#d7ba7d\",\"textSeparator.foreground\":\"#ffffff2e\",\"editor.background\":\"#23262f\",\"editor.foreground\":\"#d6deeb\",\"editorLineNumber.foreground\":\"#4b6479\",\"editorLineNumber.activeForeground\":\"#c5e4fd\",\"editorActiveLineNumber.foreground\":\"#c6c6c6\",\"editor.selectionBackground\":\"#1d3b53\",\"editor.inactiveSelectionBackground\":\"#7e57c25a\",\"editor.selectionHighlightBackground\":\"#5f7e9779\",\"editorError.foreground\":\"#ef5350\",\"editorWarning.foreground\":\"#b39554\",\"editorInfo.foreground\":\"#3794ff\",\"editorHint.foreground\":\"#eeeeeeb2\",\"problemsErrorIcon.foreground\":\"#ef5350\",\"problemsWarningIcon.foreground\":\"#b39554\",\"problemsInfoIcon.foreground\":\"#3794ff\",\"editor.findMatchBackground\":\"#5f7e9779\",\"editor.findMatchHighlightBackground\":\"#1085bb5d\",\"editor.findRangeHighlightBackground\":\"#3a3d4166\",\"editorLink.activeForeground\":\"#4e94ce\",\"editorLightBulb.foreground\":\"#ffcc00\",\"editorLightBulbAutoFix.foreground\":\"#75beff\",\"diffEditor.insertedTextBackground\":\"#99b76d23\",\"diffEditor.insertedTextBorder\":\"#c5e47833\",\"diffEditor.removedTextBackground\":\"#ef535033\",\"diffEditor.removedTextBorder\":\"#ef53504d\",\"diffEditor.insertedLineBackground\":\"#9bb95533\",\"diffEditor.removedLineBackground\":\"#ff000033\",\"editorStickyScroll.background\":\"#011627\",\"editorStickyScrollHover.background\":\"#2a2d2e\",\"editorInlayHint.background\":\"#5f7e97cc\",\"editorInlayHint.foreground\":\"#ffffff\",\"editorInlayHint.typeBackground\":\"#5f7e97cc\",\"editorInlayHint.typeForeground\":\"#ffffff\",\"editorInlayHint.parameterBackground\":\"#5f7e97cc\",\"editorInlayHint.parameterForeground\":\"#ffffff\",\"editorPane.background\":\"#011627\",\"editorGroup.emptyBackground\":\"#011627\",\"editorGroup.focusedEmptyBorder\":null,\"editorGroupHeader.tabsBackground\":\"var(--sl-color-black)\",\"editorGroupHeader.tabsBorder\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"editorGroupHeader.noTabsBackground\":\"#011627\",\"editorGroupHeader.border\":null,\"editorGroup.border\":\"#011627\",\"editorGroup.dropBackground\":\"#7e57c273\",\"editorGroup.dropIntoPromptForeground\":\"#d6deeb\",\"editorGroup.dropIntoPromptBackground\":\"#021320\",\"editorGroup.dropIntoPromptBorder\":null,\"sideBySideEditor.horizontalBorder\":\"#011627\",\"sideBySideEditor.verticalBorder\":\"#011627\",\"scrollbar.shadow\":\"#010b14\",\"scrollbarSlider.background\":\"#ffffff17\",\"scrollbarSlider.hoverBackground\":\"#ffffff40\",\"scrollbarSlider.activeBackground\":\"#084d8180\",\"panel.background\":\"#011627\",\"panel.border\":\"#5f7e97\",\"panelTitle.activeBorder\":\"#5f7e97\",\"panelTitle.activeForeground\":\"#ffffffcc\",\"panelTitle.inactiveForeground\":\"#d6deeb80\",\"panelSectionHeader.background\":\"#80808051\",\"terminal.background\":\"#011627\",\"widget.shadow\":\"#011627\",\"editorWidget.background\":\"#021320\",\"editorWidget.foreground\":\"#d6deeb\",\"editorWidget.border\":\"#5f7e97\",\"quickInput.background\":\"#021320\",\"quickInput.foreground\":\"#d6deeb\",\"quickInputTitle.background\":\"#ffffff1a\",\"pickerGroup.foreground\":\"#d1aaff\",\"pickerGroup.border\":\"#011627\",\"editor.hoverHighlightBackground\":\"#7e57c25a\",\"editorHoverWidget.background\":\"#011627\",\"editorHoverWidget.foreground\":\"#d6deeb\",\"editorHoverWidget.border\":\"#5f7e97\",\"editorHoverWidget.statusBarBackground\":\"#011a2f\",\"titleBar.activeBackground\":\"var(--sl-color-black)\",\"titleBar.activeForeground\":\"var(--sl-color-text)\",\"titleBar.inactiveBackground\":\"#010e1a\",\"titleBar.inactiveForeground\":\"#eeefff99\",\"titleBar.border\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"toolbar.hoverBackground\":\"#5a5d5e50\",\"toolbar.activeBackground\":\"#63666750\",\"tab.activeBackground\":\"#0b2942\",\"tab.unfocusedActiveBackground\":\"#0b2942\",\"tab.inactiveBackground\":\"#01111d\",\"tab.unfocusedInactiveBackground\":\"#01111d\",\"tab.activeForeground\":\"var(--sl-color-text)\",\"tab.inactiveForeground\":\"#5f7e97\",\"tab.unfocusedActiveForeground\":\"#5f7e97\",\"tab.unfocusedInactiveForeground\":\"#5f7e97\",\"tab.hoverBackground\":null,\"tab.unfocusedHoverBackground\":null,\"tab.hoverForeground\":null,\"tab.unfocusedHoverForeground\":null,\"tab.border\":\"#272b3b\",\"tab.lastPinnedBorder\":\"#585858\",\"tab.activeBorder\":\"transparent\",\"tab.unfocusedActiveBorder\":\"#262a39\",\"tab.activeBorderTop\":\"var(--sl-color-accent-high)\",\"tab.unfocusedActiveBorderTop\":null,\"tab.hoverBorder\":null,\"tab.unfocusedHoverBorder\":null,\"tab.activeModifiedBorder\":\"#3399cc\",\"tab.inactiveModifiedBorder\":\"#3399cc80\",\"tab.unfocusedActiveModifiedBorder\":\"#3399cc80\",\"tab.unfocusedInactiveModifiedBorder\":\"#3399cc40\",\"badge.background\":\"#5f7e97\",\"badge.foreground\":\"#ffffff\",\"button.background\":\"#7e57c2cc\",\"button.foreground\":\"#ffffffcc\",\"button.border\":\"#122d42\",\"button.separator\":\"#ffffff52\",\"button.hoverBackground\":\"#7e57c2\",\"button.secondaryBackground\":\"#3a3d41\",\"button.secondaryForeground\":\"#ffffff\",\"button.secondaryHoverBackground\":\"#46494e\",\"dropdown.background\":\"#011627\",\"dropdown.foreground\":\"#ffffffcc\",\"dropdown.border\":\"#5f7e97\",\"list.activeSelectionBackground\":\"#234d708c\",\"list.activeSelectionForeground\":\"#ffffff\",\"tree.indentGuidesStroke\":\"#585858\",\"input.background\":\"#0b253a\",\"input.foreground\":\"#ffffffcc\",\"input.placeholderForeground\":\"#5f7e97\",\"inputOption.activeBorder\":\"#ffffffcc\",\"inputOption.hoverBackground\":\"#5a5d5e80\",\"inputOption.activeBackground\":\"#122d4266\",\"inputOption.activeForeground\":\"#ffffff\",\"inputValidation.infoBackground\":\"#00589ef2\",\"inputValidation.infoBorder\":\"#64b5f6\",\"inputValidation.warningBackground\":\"#675700f2\",\"inputValidation.warningBorder\":\"#ffca28\",\"inputValidation.errorBackground\":\"#ab0300f2\",\"inputValidation.errorBorder\":\"#ef5350\",\"keybindingLabel.background\":\"#8080802b\",\"keybindingLabel.foreground\":\"#cccccc\",\"keybindingLabel.border\":\"#33333399\",\"keybindingLabel.bottomBorder\":\"#44444499\",\"menu.foreground\":\"#ffffffcc\",\"menu.background\":\"#011627\",\"menu.selectionForeground\":\"#ffffff\",\"menu.selectionBackground\":\"#234d708c\",\"menu.separatorBackground\":\"#606060\",\"editor.snippetTabstopHighlightBackground\":\"#7c7c74c\",\"editor.snippetFinalTabstopHighlightBorder\":\"#525252\",\"terminal.ansiBlack\":\"#011627\",\"terminal.ansiRed\":\"#ef5350\",\"terminal.ansiGreen\":\"#22da6e\",\"terminal.ansiYellow\":\"#c5e478\",\"terminal.ansiBlue\":\"#82aaff\",\"terminal.ansiMagenta\":\"#c792ea\",\"terminal.ansiCyan\":\"#21c7a8\",\"terminal.ansiWhite\":\"#ffffff\",\"terminal.ansiBrightBlack\":\"#575656\",\"terminal.ansiBrightRed\":\"#ef5350\",\"terminal.ansiBrightGreen\":\"#22da6e\",\"terminal.ansiBrightYellow\":\"#ffeb95\",\"terminal.ansiBrightBlue\":\"#82aaff\",\"terminal.ansiBrightMagenta\":\"#c792ea\",\"terminal.ansiBrightCyan\":\"#7fdbca\",\"terminal.ansiBrightWhite\":\"#ffffff\",\"selection.background\":\"#4373c2\",\"input.border\":\"#5f7e97\",\"punctuation.definition.generic.begin.html\":\"#ef5350f2\",\"progress.background\":\"#7e57c2\",\"breadcrumb.foreground\":\"#a599e9\",\"breadcrumb.focusForeground\":\"#ffffff\",\"breadcrumb.activeSelectionForeground\":\"#ffffff\",\"breadcrumbPicker.background\":\"#001122\",\"list.invalidItemForeground\":\"#975f94\",\"list.dropBackground\":\"#011627\",\"list.focusBackground\":\"#010d18\",\"list.focusForeground\":\"#ffffff\",\"list.highlightForeground\":\"#ffffff\",\"list.hoverBackground\":\"#011627\",\"list.hoverForeground\":\"#ffffff\",\"list.inactiveSelectionBackground\":\"#0e293f\",\"list.inactiveSelectionForeground\":\"#5f7e97\",\"activityBar.background\":\"#011627\",\"activityBar.dropBackground\":\"#5f7e97\",\"activityBar.foreground\":\"#5f7e97\",\"activityBar.border\":\"#011627\",\"activityBarBadge.background\":\"#44596b\",\"activityBarBadge.foreground\":\"#ffffff\",\"sideBar.background\":\"#011627\",\"sideBar.foreground\":\"#89a4bb\",\"sideBar.border\":\"#011627\",\"sideBarTitle.foreground\":\"#5f7e97\",\"sideBarSectionHeader.background\":\"#011627\",\"sideBarSectionHeader.foreground\":\"#5f7e97\",\"editorCursor.foreground\":\"#80a4c2\",\"editor.wordHighlightBackground\":\"#f6bbe533\",\"editor.wordHighlightStrongBackground\":\"#e2a2f433\",\"editor.lineHighlightBackground\":\"#0003\",\"editor.rangeHighlightBackground\":\"#7e57c25a\",\"editorIndentGuide.background\":\"#5e81ce52\",\"editorIndentGuide.activeBackground\":\"#7e97ac\",\"editorRuler.foreground\":\"#5e81ce52\",\"editorCodeLens.foreground\":\"#5e82ceb4\",\"editorBracketMatch.background\":\"#5f7e974d\",\"editorOverviewRuler.currentContentForeground\":\"#7e57c2\",\"editorOverviewRuler.incomingContentForeground\":\"#7e57c2\",\"editorOverviewRuler.commonContentForeground\":\"#7e57c2\",\"editorGutter.background\":\"#011627\",\"editorGutter.modifiedBackground\":\"#e2b93d\",\"editorGutter.addedBackground\":\"#9ccc65\",\"editorGutter.deletedBackground\":\"#ef5350\",\"editorSuggestWidget.background\":\"#2c3043\",\"editorSuggestWidget.border\":\"#2b2f40\",\"editorSuggestWidget.foreground\":\"#d6deeb\",\"editorSuggestWidget.highlightForeground\":\"#ffffff\",\"editorSuggestWidget.selectedBackground\":\"#5f7e97\",\"debugExceptionWidget.background\":\"#011627\",\"debugExceptionWidget.border\":\"#5f7e97\",\"editorMarkerNavigation.background\":\"#0b2942\",\"editorMarkerNavigationError.background\":\"#ef5350\",\"editorMarkerNavigationWarning.background\":\"#ffca28\",\"peekView.border\":\"#5f7e97\",\"peekViewEditor.background\":\"#011627\",\"peekViewEditor.matchHighlightBackground\":\"#7e57c25a\",\"peekViewResult.background\":\"#011627\",\"peekViewResult.fileForeground\":\"#5f7e97\",\"peekViewResult.lineForeground\":\"#5f7e97\",\"peekViewResult.matchHighlightBackground\":\"#ffffffcc\",\"peekViewResult.selectionBackground\":\"#2e3250\",\"peekViewResult.selectionForeground\":\"#5f7e97\",\"peekViewTitle.background\":\"#011627\",\"peekViewTitleDescription.foreground\":\"#697098\",\"peekViewTitleLabel.foreground\":\"#5f7e97\",\"merge.currentHeaderBackground\":\"#5f7e97\",\"merge.incomingHeaderBackground\":\"#7e57c25a\",\"statusBar.background\":\"#011627\",\"statusBar.foreground\":\"#5f7e97\",\"statusBar.border\":\"#262a39\",\"statusBar.debuggingBackground\":\"#202431\",\"statusBar.debuggingBorder\":\"#1f2330\",\"statusBar.noFolderBackground\":\"#011627\",\"statusBar.noFolderBorder\":\"#25293a\",\"statusBarItem.activeBackground\":\"#202431\",\"statusBarItem.hoverBackground\":\"#202431\",\"statusBarItem.prominentBackground\":\"#202431\",\"statusBarItem.prominentHoverBackground\":\"#202431\",\"notifications.background\":\"#01111d\",\"notifications.border\":\"#262a39\",\"notificationCenter.border\":\"#262a39\",\"notificationToast.border\":\"#262a39\",\"notifications.foreground\":\"#ffffffcc\",\"notificationLink.foreground\":\"#80cbc4\",\"extensionButton.prominentForeground\":\"#ffffffcc\",\"extensionButton.prominentBackground\":\"#7e57c2cc\",\"extensionButton.prominentHoverBackground\":\"#7e57c2\",\"terminal.selectionBackground\":\"#1b90dd4d\",\"terminalCursor.background\":\"#234d70\",\"debugToolBar.background\":\"#011627\",\"welcomePage.buttonBackground\":\"#011627\",\"welcomePage.buttonHoverBackground\":\"#011627\",\"walkThrough.embeddedEditorBackground\":\"#011627\",\"gitDecoration.modifiedResourceForeground\":\"#a2bffc\",\"gitDecoration.deletedResourceForeground\":\"#ef535090\",\"gitDecoration.untrackedResourceForeground\":\"#c5e478ff\",\"gitDecoration.ignoredResourceForeground\":\"#395a75\",\"gitDecoration.conflictingResourceForeground\":\"#ffeb95cc\",\"source.elm\":\"#5f7e97\",\"string.quoted.single.js\":\"#ffffff\",\"meta.objectliteral.js\":\"#82aaff\"},\"fg\":\"#d6deeb\",\"bg\":\"#23262f\",\"semanticHighlighting\":false,\"settings\":[{\"name\":\"Changed\",\"scope\":[\"markup.changed\",\"meta.diff.header.git\",\"meta.diff.header.from-file\",\"meta.diff.header.to-file\"],\"settings\":{\"foreground\":\"#a2bffc\"}},{\"name\":\"Deleted\",\"scope\":[\"markup.deleted.diff\"],\"settings\":{\"foreground\":\"#f27775fe\"}},{\"name\":\"Inserted\",\"scope\":[\"markup.inserted.diff\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Global settings\",\"settings\":{\"background\":\"#011627\",\"foreground\":\"#d6deeb\"}},{\"name\":\"Comment\",\"scope\":[\"comment\"],\"settings\":{\"foreground\":\"#919f9f\",\"fontStyle\":\"\"}},{\"name\":\"String\",\"scope\":[\"string\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"String Quoted\",\"scope\":[\"string.quoted\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"Support Constant Math\",\"scope\":[\"support.constant.math\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Number\",\"scope\":[\"constant.numeric\",\"constant.character.numeric\"],\"settings\":{\"foreground\":\"#f78c6c\",\"fontStyle\":\"\"}},{\"name\":\"Built-in constant\",\"scope\":[\"constant.language\",\"punctuation.definition.constant\",\"variable.other.constant\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"User-defined constant\",\"scope\":[\"constant.character\",\"constant.other\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Constant Character Escape\",\"scope\":[\"constant.character.escape\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"RegExp String\",\"scope\":[\"string.regexp\",\"string.regexp keyword.other\"],\"settings\":{\"foreground\":\"#5ca7e4\"}},{\"name\":\"Comma in functions\",\"scope\":[\"meta.function punctuation.separator.comma\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"Variable\",\"scope\":[\"variable\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Keyword\",\"scope\":[\"punctuation.accessor\",\"keyword\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Storage\",\"scope\":[\"storage\",\"meta.var.expr\",\"meta.class meta.method.declaration meta.var.expr storage.type.js\",\"storage.type.property.js\",\"storage.type.property.ts\",\"storage.type.property.tsx\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type.function.arrow.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Class name\",\"scope\":[\"entity.name.class\",\"meta.class entity.name.type.class\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Inherited class\",\"scope\":[\"entity.other.inherited-class\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Function name\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Meta Tag\",\"scope\":[\"punctuation.definition.tag\",\"meta.tag\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"HTML Tag names\",\"scope\":[\"entity.name.tag\",\"meta.tag.other.html\",\"meta.tag.other.js\",\"meta.tag.other.tsx\",\"entity.name.tag.tsx\",\"entity.name.tag.js\",\"entity.name.tag\",\"meta.tag.js\",\"meta.tag.tsx\",\"meta.tag.html\"],\"settings\":{\"foreground\":\"#caece6\",\"fontStyle\":\"\"}},{\"name\":\"Tag attribute\",\"scope\":[\"entity.other.attribute-name\"],\"settings\":{\"fontStyle\":\"\",\"foreground\":\"#c5e478\"}},{\"name\":\"Entity Name Tag Custom\",\"scope\":[\"entity.name.tag.custom\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Library (function & constant)\",\"scope\":[\"support.function\",\"support.constant\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Support Constant Property Value meta\",\"scope\":[\"support.constant.meta.property-value\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Library class/type\",\"scope\":[\"support.type\",\"support.class\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Support Variable DOM\",\"scope\":[\"support.variable.dom\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Invalid\",\"scope\":[\"invalid\"],\"settings\":{\"background\":\"#ff2c83\",\"foreground\":\"#ffffff\"}},{\"name\":\"Invalid deprecated\",\"scope\":[\"invalid.deprecated\"],\"settings\":{\"foreground\":\"#ffffff\",\"background\":\"#d3423e\"}},{\"name\":\"Keyword Operator\",\"scope\":[\"keyword.operator\"],\"settings\":{\"foreground\":\"#7fdbca\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Relational\",\"scope\":[\"keyword.operator.relational\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Assignment\",\"scope\":[\"keyword.operator.assignment\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Arithmetic\",\"scope\":[\"keyword.operator.arithmetic\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Bitwise\",\"scope\":[\"keyword.operator.bitwise\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Increment\",\"scope\":[\"keyword.operator.increment\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Ternary\",\"scope\":[\"keyword.operator.ternary\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Double-Slashed Comment\",\"scope\":[\"comment.line.double-slash\"],\"settings\":{\"foreground\":\"#919f9f\"}},{\"name\":\"Object\",\"scope\":[\"object\"],\"settings\":{\"foreground\":\"#cdebf7\"}},{\"name\":\"Null\",\"scope\":[\"constant.language.null\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Meta Brace\",\"scope\":[\"meta.brace\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Meta Delimiter Period\",\"scope\":[\"meta.delimiter.period\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Punctuation Definition String\",\"scope\":[\"punctuation.definition.string\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Punctuation Definition String Markdown\",\"scope\":[\"punctuation.definition.string.begin.markdown\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Boolean\",\"scope\":[\"constant.language.boolean\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Object Comma\",\"scope\":[\"object.comma\"],\"settings\":{\"foreground\":\"#ffffff\"}},{\"name\":\"Variable Parameter Function\",\"scope\":[\"variable.parameter.function\"],\"settings\":{\"foreground\":\"#7fdbca\",\"fontStyle\":\"\"}},{\"name\":\"Support Type Property Name & entity name tags\",\"scope\":[\"support.type.vendor.property-name\",\"support.constant.vendor.property-value\",\"support.type.property-name\",\"meta.property-list entity.name.tag\"],\"settings\":{\"foreground\":\"#80cbc4\",\"fontStyle\":\"\"}},{\"name\":\"Entity Name tag reference in stylesheets\",\"scope\":[\"meta.property-list entity.name.tag.reference\"],\"settings\":{\"foreground\":\"#57eaf1\"}},{\"name\":\"Constant Other Color RGB Value Punctuation Definition Constant\",\"scope\":[\"constant.other.color.rgb-value punctuation.definition.constant\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Constant Other Color\",\"scope\":[\"constant.other.color\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Keyword Other Unit\",\"scope\":[\"keyword.other.unit\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Meta Selector\",\"scope\":[\"meta.selector\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Entity Other Attribute Name Id\",\"scope\":[\"entity.other.attribute-name.id\"],\"settings\":{\"foreground\":\"#fad430\"}},{\"name\":\"Meta Property Name\",\"scope\":[\"meta.property-name\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Doctypes\",\"scope\":[\"entity.name.tag.doctype\",\"meta.tag.sgml.doctype\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Punctuation Definition Parameters\",\"scope\":[\"punctuation.definition.parameters\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Keyword Control Operator\",\"scope\":[\"keyword.control.operator\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Keyword Operator Logical\",\"scope\":[\"keyword.operator.logical\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Variable Instances\",\"scope\":[\"variable.instance\",\"variable.other.instance\",\"variable.readwrite.instance\",\"variable.other.readwrite.instance\",\"variable.other.property\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Variable Property Other object property\",\"scope\":[\"variable.other.object.property\"],\"settings\":{\"foreground\":\"#faf39f\",\"fontStyle\":\"\"}},{\"name\":\"Variable Property Other object\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Entity Name Function\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#82aaff\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Comparison, returns, imports, and Keyword Operator Ruby\",\"scope\":[\"keyword.control.conditional.js\",\"keyword.operator.comparison\",\"keyword.control.flow.js\",\"keyword.control.flow.ts\",\"keyword.control.flow.tsx\",\"keyword.control.ruby\",\"keyword.control.def.ruby\",\"keyword.control.loop.js\",\"keyword.control.loop.ts\",\"keyword.control.import.js\",\"keyword.control.import.ts\",\"keyword.control.import.tsx\",\"keyword.control.from.js\",\"keyword.control.from.ts\",\"keyword.control.from.tsx\",\"keyword.control.conditional.js\",\"keyword.control.conditional.ts\",\"keyword.control.switch.js\",\"keyword.control.switch.ts\",\"keyword.operator.instanceof.js\",\"keyword.operator.expression.instanceof.ts\",\"keyword.operator.expression.instanceof.tsx\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Support Constant, `new` keyword, Special Method Keyword, `debugger`, other keywords\",\"scope\":[\"support.constant\",\"keyword.other.special-method\",\"keyword.other.new\",\"keyword.other.debugger\",\"keyword.control\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Support Function\",\"scope\":[\"support.function\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Invalid Broken\",\"scope\":[\"invalid.broken\"],\"settings\":{\"foreground\":\"#989da0\",\"background\":\"#F78C6C\"}},{\"name\":\"Invalid Unimplemented\",\"scope\":[\"invalid.unimplemented\"],\"settings\":{\"background\":\"#8BD649\",\"foreground\":\"#ffffff\"}},{\"name\":\"Invalid Illegal\",\"scope\":[\"invalid.illegal\"],\"settings\":{\"foreground\":\"#ffffff\",\"background\":\"#ec5f67\"}},{\"name\":\"Language Variable\",\"scope\":[\"variable.language\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Support Variable Property\",\"scope\":[\"support.variable.property\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Variable Function\",\"scope\":[\"variable.function\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Variable Interpolation\",\"scope\":[\"variable.interpolation\"],\"settings\":{\"foreground\":\"#ef787f\"}},{\"name\":\"Meta Function Call\",\"scope\":[\"meta.function-call\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Punctuation Section Embedded\",\"scope\":[\"punctuation.section.embedded\"],\"settings\":{\"foreground\":\"#e2817f\"}},{\"name\":\"Punctuation Tweaks\",\"scope\":[\"punctuation.terminator.expression\",\"punctuation.definition.arguments\",\"punctuation.definition.array\",\"punctuation.section.array\",\"meta.array\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"More Punctuation Tweaks\",\"scope\":[\"punctuation.definition.list.begin\",\"punctuation.definition.list.end\",\"punctuation.separator.arguments\",\"punctuation.definition.list\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Template Strings\",\"scope\":[\"string.template meta.template.expression\"],\"settings\":{\"foreground\":\"#e2817f\"}},{\"name\":\"Backticks(``) in Template Strings\",\"scope\":[\"string.template punctuation.definition.string\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Italics\",\"scope\":[\"italic\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"italic\"}},{\"name\":\"Bold\",\"scope\":[\"bold\"],\"settings\":{\"foreground\":\"#c5e478\",\"fontStyle\":\"bold\"}},{\"name\":\"Quote\",\"scope\":[\"quote\"],\"settings\":{\"foreground\":\"#969bb7\",\"fontStyle\":\"\"}},{\"name\":\"Raw Code\",\"scope\":[\"raw\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"CoffeeScript Variable Assignment\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#31e1eb\"}},{\"name\":\"CoffeeScript Parameter Function\",\"scope\":[\"variable.parameter.function.coffee\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"CoffeeScript Assignments\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"C# Readwrite Variables\",\"scope\":[\"variable.other.readwrite.cs\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C# Classes & Storage types\",\"scope\":[\"entity.name.type.class.cs\",\"storage.type.cs\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"C# Namespaces\",\"scope\":[\"entity.name.type.namespace.cs\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"C# Unquoted String Zone\",\"scope\":[\"string.unquoted.preprocessor.message.cs\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C# Region\",\"scope\":[\"punctuation.separator.hash.cs\",\"keyword.preprocessor.region.cs\",\"keyword.preprocessor.endregion.cs\"],\"settings\":{\"foreground\":\"#ffcb8b\",\"fontStyle\":\"bold\"}},{\"name\":\"C# Other Variables\",\"scope\":[\"variable.other.object.cs\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"C# Enum\",\"scope\":[\"entity.name.type.enum.cs\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Dart String\",\"scope\":[\"string.interpolated.single.dart\",\"string.interpolated.double.dart\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Dart Class\",\"scope\":[\"support.class.dart\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Tag names in Stylesheets\",\"scope\":[\"entity.name.tag.css\",\"entity.name.tag.less\",\"entity.name.tag.custom.css\",\"support.constant.property-value.css\"],\"settings\":{\"foreground\":\"#ff6d6d\",\"fontStyle\":\"\"}},{\"name\":\"Wildcard(*) selector in Stylesheets\",\"scope\":[\"entity.name.tag.wildcard.css\",\"entity.name.tag.wildcard.less\",\"entity.name.tag.wildcard.scss\",\"entity.name.tag.wildcard.sass\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"CSS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Attribute Name for CSS\",\"scope\":[\"meta.attribute-selector.css entity.other.attribute-name.attribute\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Elixir Classes\",\"scope\":[\"source.elixir support.type.elixir\",\"source.elixir meta.module.elixir entity.name.class.elixir\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Elixir Functions\",\"scope\":[\"source.elixir entity.name.function\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir Constants\",\"scope\":[\"source.elixir constant.other.symbol.elixir\",\"source.elixir constant.other.keywords.elixir\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Elixir String Punctuations\",\"scope\":[\"source.elixir punctuation.definition.string\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir\",\"scope\":[\"source.elixir variable.other.readwrite.module.elixir\",\"source.elixir variable.other.readwrite.module.elixir punctuation.definition.variable.elixir\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir Binary Punctuations\",\"scope\":[\"source.elixir .punctuation.binary.elixir\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Closure Constant Keyword\",\"scope\":[\"constant.keyword.clojure\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Go Function Calls\",\"scope\":[\"source.go meta.function-call.go\"],\"settings\":{\"foreground\":\"#dddddd\"}},{\"name\":\"Go Keywords\",\"scope\":[\"source.go keyword.package.go\",\"source.go keyword.import.go\",\"source.go keyword.function.go\",\"source.go keyword.type.go\",\"source.go keyword.struct.go\",\"source.go keyword.interface.go\",\"source.go keyword.const.go\",\"source.go keyword.var.go\",\"source.go keyword.map.go\",\"source.go keyword.channel.go\",\"source.go keyword.control.go\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Go Constants e.g. nil, string format (%s, %d, etc.)\",\"scope\":[\"source.go constant.language.go\",\"source.go constant.other.placeholder.go\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"C++ Functions\",\"scope\":[\"entity.name.function.preprocessor.cpp\",\"entity.scope.name.cpp\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"C++ Meta Namespace\",\"scope\":[\"meta.namespace-block.cpp\"],\"settings\":{\"foreground\":\"#e0dec6\"}},{\"name\":\"C++ Language Primitive Storage\",\"scope\":[\"storage.type.language.primitive.cpp\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"C++ Preprocessor Macro\",\"scope\":[\"meta.preprocessor.macro.cpp\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C++ Variable Parameter\",\"scope\":[\"variable.parameter\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Powershell Variables\",\"scope\":[\"variable.other.readwrite.powershell\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Powershell Function\",\"scope\":[\"support.function.powershell\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"ID Attribute Name in HTML\",\"scope\":[\"entity.other.attribute-name.id.html\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"HTML Punctuation Definition Tag\",\"scope\":[\"punctuation.definition.tag.html\"],\"settings\":{\"foreground\":\"#6ae9f0\"}},{\"name\":\"HTML Doctype\",\"scope\":[\"meta.tag.sgml.doctype.html\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Classes\",\"scope\":[\"meta.class entity.name.type.class.js\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"JavaScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.js\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"JavaScript Terminator\",\"scope\":[\"terminator.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Meta Punctuation Definition\",\"scope\":[\"meta.js punctuation.definition.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Entity Names in Code Documentations\",\"scope\":[\"entity.name.type.instance.jsdoc\",\"entity.name.type.instance.phpdoc\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"Other Variables in Code Documentations\",\"scope\":[\"variable.other.jsdoc\",\"variable.other.phpdoc\"],\"settings\":{\"foreground\":\"#78ccf0\"}},{\"name\":\"JavaScript module imports and exports\",\"scope\":[\"variable.other.meta.import.js\",\"meta.import.js variable.other\",\"variable.other.meta.export.js\",\"meta.export.js variable.other\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Variable Parameter Function\",\"scope\":[\"variable.parameter.function.js\"],\"settings\":{\"foreground\":\"#8b96ea\"}},{\"name\":\"JavaScript[React] Variable Other Object\",\"scope\":[\"variable.other.object.js\",\"variable.other.object.jsx\",\"variable.object.property.js\",\"variable.object.property.jsx\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Variables\",\"scope\":[\"variable.js\",\"variable.other.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Entity Name Type\",\"scope\":[\"entity.name.type.js\",\"entity.name.type.module.js\"],\"settings\":{\"foreground\":\"#ffcb8b\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Support Classes\",\"scope\":[\"support.class.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JSON Property Names\",\"scope\":[\"support.type.property-name.json\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"JSON Support Constants\",\"scope\":[\"support.constant.json\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"JSON Property values (string)\",\"scope\":[\"meta.structure.dictionary.value.json string.quoted.double\"],\"settings\":{\"foreground\":\"#c789d6\"}},{\"name\":\"Strings in JSON values\",\"scope\":[\"string.quoted.double.json punctuation.definition.string.json\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Specific JSON Property values like null\",\"scope\":[\"meta.structure.dictionary.json meta.structure.dictionary.value constant.language\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"JavaScript Other Variable\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Ruby Variables\",\"scope\":[\"variable.other.ruby\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Ruby Class\",\"scope\":[\"entity.name.type.class.ruby\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"Ruby Hashkeys\",\"scope\":[\"constant.language.symbol.hashkey.ruby\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"LESS Tag names\",\"scope\":[\"entity.name.tag.less\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"LESS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Attribute Name for LESS\",\"scope\":[\"meta.attribute-selector.less entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Markdown Headings\",\"scope\":[\"markup.heading.markdown\",\"markup.heading.setext.1.markdown\",\"markup.heading.setext.2.markdown\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown Italics\",\"scope\":[\"markup.italic.markdown\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"italic\"}},{\"name\":\"Markdown Bold\",\"scope\":[\"markup.bold.markdown\"],\"settings\":{\"foreground\":\"#c5e478\",\"fontStyle\":\"bold\"}},{\"name\":\"Markdown Quote + others\",\"scope\":[\"markup.quote.markdown\"],\"settings\":{\"foreground\":\"#969bb7\",\"fontStyle\":\"\"}},{\"name\":\"Markdown Raw Code + others\",\"scope\":[\"markup.inline.raw.markdown\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Markdown Links\",\"scope\":[\"markup.underline.link.markdown\",\"markup.underline.link.image.markdown\"],\"settings\":{\"foreground\":\"#ff869a\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Link Title and Description\",\"scope\":[\"string.other.link.title.markdown\",\"string.other.link.description.markdown\"],\"settings\":{\"foreground\":\"#d6deeb\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Punctuation\",\"scope\":[\"punctuation.definition.string.markdown\",\"punctuation.definition.string.begin.markdown\",\"punctuation.definition.string.end.markdown\",\"meta.link.inline.markdown punctuation.definition.string\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown MetaData Punctuation\",\"scope\":[\"punctuation.definition.metadata.markdown\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Markdown List Punctuation\",\"scope\":[\"beginning.punctuation.definition.list.markdown\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown Inline Raw String\",\"scope\":[\"markup.inline.raw.string.markdown\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"PHP Variables\",\"scope\":[\"variable.other.php\"],\"settings\":{\"foreground\":\"#bec5d4\"}},{\"name\":\"Support Classes in PHP\",\"scope\":[\"support.class.php\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Punctuations in PHP function calls\",\"scope\":[\"meta.function-call.php punctuation\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"PHP Global Variables\",\"scope\":[\"variable.other.global.php\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Declaration Punctuation in PHP Global Variables\",\"scope\":[\"variable.other.global.php punctuation.definition.variable\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Language Constants in Python\",\"scope\":[\"constant.language.python\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Python Function Parameter and Arguments\",\"scope\":[\"variable.parameter.function.python\",\"meta.function-call.arguments.python\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Python Function Call\",\"scope\":[\"meta.function-call.python\",\"meta.function-call.generic.python\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"Punctuations in Python\",\"scope\":[\"punctuation.python\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Decorator Functions in Python\",\"scope\":[\"entity.name.function.decorator.python\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Python Language Variable\",\"scope\":[\"source.python variable.language.special\"],\"settings\":{\"foreground\":\"#8eace3\"}},{\"name\":\"Python import control keyword\",\"scope\":[\"keyword.control\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"SCSS Variable\",\"scope\":[\"variable.scss\",\"variable.sass\",\"variable.parameter.url.scss\",\"variable.parameter.url.sass\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#bec5d4\"}},{\"name\":\"Attribute Name for SASS\",\"scope\":[\"meta.attribute-selector.scss entity.other.attribute-name.attribute\",\"meta.attribute-selector.sass entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Tag names in SASS\",\"scope\":[\"entity.name.tag.scss\",\"entity.name.tag.sass\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"SASS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.scss\",\"keyword.other.unit.sass\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"TypeScript[React] Variables and Object Properties\",\"scope\":[\"variable.other.readwrite.alias.ts\",\"variable.other.readwrite.alias.tsx\",\"variable.other.readwrite.ts\",\"variable.other.readwrite.tsx\",\"variable.other.object.ts\",\"variable.other.object.tsx\",\"variable.object.property.ts\",\"variable.object.property.tsx\",\"variable.other.ts\",\"variable.other.tsx\",\"variable.tsx\",\"variable.ts\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript[React] Entity Name Types\",\"scope\":[\"entity.name.type.ts\",\"entity.name.type.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript[React] Node Classes\",\"scope\":[\"support.class.node.ts\",\"support.class.node.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"TypeScript[React] Entity Name Types as Parameters\",\"scope\":[\"meta.type.parameters.ts entity.name.type\",\"meta.type.parameters.tsx entity.name.type\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"TypeScript[React] Import/Export Punctuations\",\"scope\":[\"meta.import.ts punctuation.definition.block\",\"meta.import.tsx punctuation.definition.block\",\"meta.export.ts punctuation.definition.block\",\"meta.export.tsx punctuation.definition.block\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.decorator punctuation.decorator.ts\",\"meta.decorator punctuation.decorator.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.tag.js meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"YAML Entity Name Tags\",\"scope\":[\"entity.name.tag.yaml\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"JavaScript Variable Other ReadWrite\",\"scope\":[\"variable.other.readwrite.js\",\"variable.parameter\"],\"settings\":{\"foreground\":\"#d7dbe0\"}},{\"name\":\"Support Class Component\",\"scope\":[\"support.class.component.js\",\"support.class.component.tsx\"],\"settings\":{\"foreground\":\"#f78c6c\",\"fontStyle\":\"\"}},{\"name\":\"Text nested in React tags\",\"scope\":[\"meta.jsx.children\",\"meta.jsx.children.js\",\"meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript Classes\",\"scope\":[\"meta.class entity.name.type.class.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript Entity Name Type\",\"scope\":[\"entity.name.type.tsx\",\"entity.name.type.module.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript Class Variable Keyword\",\"scope\":[\"meta.class.ts meta.var.expr.ts storage.type.ts\",\"meta.class.tsx meta.var.expr.tsx storage.type.tsx\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"TypeScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.ts\",\"meta.method.declaration storage.type.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"normalize font style of certain components\",\"scope\":[\"meta.property-list.css meta.property-value.css variable.other.less\",\"meta.property-list.scss variable.scss\",\"meta.property-list.sass variable.sass\",\"meta.brace\",\"keyword.operator.operator\",\"keyword.operator.or.regexp\",\"keyword.operator.expression.in\",\"keyword.operator.relational\",\"keyword.operator.assignment\",\"keyword.operator.comparison\",\"keyword.operator.type\",\"keyword.operator\",\"keyword\",\"punctuation.definition.string\",\"punctuation\",\"variable.other.readwrite.js\",\"storage.type\",\"source.css\",\"string.quoted\"],\"settings\":{\"fontStyle\":\"\"}}],\"styleOverrides\":{\"frames\":{\"editorBackground\":\"var(--sl-color-gray-6)\",\"terminalBackground\":\"var(--sl-color-gray-6)\",\"editorActiveTabBackground\":\"var(--sl-color-gray-6)\",\"terminalTitlebarDotsForeground\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"terminalTitlebarDotsOpacity\":\"0.75\",\"inlineButtonForeground\":\"var(--sl-color-text)\",\"frameBoxShadowCssValue\":\"none\"},\"textMarkers\":{\"markBackground\":\"#ffffff17\",\"markBorderColor\":\"#ffffff40\"}}},{\"name\":\"Night Owl Light\",\"type\":\"light\",\"colors\":{\"focusBorder\":\"#93a1a1\",\"foreground\":\"#403f53\",\"disabledForeground\":\"#61616180\",\"descriptionForeground\":\"#403f53\",\"errorForeground\":\"#403f53\",\"icon.foreground\":\"#424242\",\"contrastActiveBorder\":null,\"contrastBorder\":null,\"textBlockQuote.background\":\"#7f7f7f1a\",\"textBlockQuote.border\":\"#007acc80\",\"textCodeBlock.background\":\"#dcdcdc66\",\"textLink.activeForeground\":\"#006ab1\",\"textLink.foreground\":\"#006ab1\",\"textPreformat.foreground\":\"#a31515\",\"textSeparator.foreground\":\"#0000002e\",\"editor.background\":\"#f6f7f9\",\"editor.foreground\":\"#403f53\",\"editorLineNumber.foreground\":\"#90a7b2\",\"editorLineNumber.activeForeground\":\"#403f53\",\"editorActiveLineNumber.foreground\":\"#0b216f\",\"editor.selectionBackground\":\"#e0e0e0\",\"editor.inactiveSelectionBackground\":\"#e0e0e080\",\"editor.selectionHighlightBackground\":\"#339cec33\",\"editorError.foreground\":\"#e64d49\",\"editorWarning.foreground\":\"#daaa01\",\"editorInfo.foreground\":\"#1a85ff\",\"editorHint.foreground\":\"#6c6c6c\",\"problemsErrorIcon.foreground\":\"#e64d49\",\"problemsWarningIcon.foreground\":\"#daaa01\",\"problemsInfoIcon.foreground\":\"#1a85ff\",\"editor.findMatchBackground\":\"#93a1a16c\",\"editor.findMatchHighlightBackground\":\"#93a1a16c\",\"editor.findRangeHighlightBackground\":\"#7497a633\",\"editorLink.activeForeground\":\"#0000ff\",\"editorLightBulb.foreground\":\"#ddb100\",\"editorLightBulbAutoFix.foreground\":\"#007acc\",\"diffEditor.insertedTextBackground\":\"#9ccc2c40\",\"diffEditor.insertedTextBorder\":null,\"diffEditor.removedTextBackground\":\"#ff000033\",\"diffEditor.removedTextBorder\":null,\"diffEditor.insertedLineBackground\":\"#9bb95533\",\"diffEditor.removedLineBackground\":\"#ff000033\",\"editorStickyScroll.background\":\"#fbfbfb\",\"editorStickyScrollHover.background\":\"#f0f0f0\",\"editorInlayHint.background\":\"#2aa29899\",\"editorInlayHint.foreground\":\"#f0f0f0\",\"editorInlayHint.typeBackground\":\"#2aa29899\",\"editorInlayHint.typeForeground\":\"#f0f0f0\",\"editorInlayHint.parameterBackground\":\"#2aa29899\",\"editorInlayHint.parameterForeground\":\"#f0f0f0\",\"editorPane.background\":\"#fbfbfb\",\"editorGroup.emptyBackground\":null,\"editorGroup.focusedEmptyBorder\":null,\"editorGroupHeader.tabsBackground\":\"var(--sl-color-gray-6)\",\"editorGroupHeader.tabsBorder\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"editorGroupHeader.noTabsBackground\":\"#f0f0f0\",\"editorGroupHeader.border\":null,\"editorGroup.border\":\"#f0f0f0\",\"editorGroup.dropBackground\":\"#2677cb2d\",\"editorGroup.dropIntoPromptForeground\":\"#403f53\",\"editorGroup.dropIntoPromptBackground\":\"#f0f0f0\",\"editorGroup.dropIntoPromptBorder\":null,\"sideBySideEditor.horizontalBorder\":\"#f0f0f0\",\"sideBySideEditor.verticalBorder\":\"#f0f0f0\",\"scrollbar.shadow\":\"#cccccc\",\"scrollbarSlider.background\":\"#0000001a\",\"scrollbarSlider.hoverBackground\":\"#00000055\",\"scrollbarSlider.activeBackground\":\"#00000099\",\"panel.background\":\"#f0f0f0\",\"panel.border\":\"#d9d9d9\",\"panelTitle.activeBorder\":\"#424242\",\"panelTitle.activeForeground\":\"#424242\",\"panelTitle.inactiveForeground\":\"#424242bf\",\"panelSectionHeader.background\":\"#80808051\",\"terminal.background\":\"#f6f6f6\",\"widget.shadow\":\"#d9d9d9\",\"editorWidget.background\":\"#f0f0f0\",\"editorWidget.foreground\":\"#403f53\",\"editorWidget.border\":\"#d9d9d9\",\"quickInput.background\":\"#f0f0f0\",\"quickInput.foreground\":\"#403f53\",\"quickInputTitle.background\":\"#0000000f\",\"pickerGroup.foreground\":\"#403f53\",\"pickerGroup.border\":\"#d9d9d9\",\"editor.hoverHighlightBackground\":\"#339cec33\",\"editorHoverWidget.background\":\"#f0f0f0\",\"editorHoverWidget.foreground\":\"#403f53\",\"editorHoverWidget.border\":\"#d9d9d9\",\"editorHoverWidget.statusBarBackground\":\"#e4e4e4\",\"titleBar.activeBackground\":\"var(--sl-color-gray-6)\",\"titleBar.activeForeground\":\"var(--sl-color-text)\",\"titleBar.inactiveBackground\":\"#f0f0f099\",\"titleBar.inactiveForeground\":\"#33333399\",\"titleBar.border\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"toolbar.hoverBackground\":\"#b8b8b850\",\"toolbar.activeBackground\":\"#a6a6a650\",\"tab.activeBackground\":\"#f6f6f6\",\"tab.unfocusedActiveBackground\":\"#f6f6f6\",\"tab.inactiveBackground\":\"#f0f0f0\",\"tab.unfocusedInactiveBackground\":\"#f0f0f0\",\"tab.activeForeground\":\"var(--sl-color-text)\",\"tab.inactiveForeground\":\"#403f53\",\"tab.unfocusedActiveForeground\":\"#403f53b3\",\"tab.unfocusedInactiveForeground\":\"#403f5380\",\"tab.hoverBackground\":null,\"tab.unfocusedHoverBackground\":null,\"tab.hoverForeground\":null,\"tab.unfocusedHoverForeground\":null,\"tab.border\":\"#f0f0f0\",\"tab.lastPinnedBorder\":\"#a9a9a9\",\"tab.activeBorder\":\"transparent\",\"tab.unfocusedActiveBorder\":null,\"tab.activeBorderTop\":\"var(--sl-color-accent)\",\"tab.unfocusedActiveBorderTop\":null,\"tab.hoverBorder\":null,\"tab.unfocusedHoverBorder\":null,\"tab.activeModifiedBorder\":\"#2aa298\",\"tab.inactiveModifiedBorder\":\"#93a1a1\",\"tab.unfocusedActiveModifiedBorder\":\"#93a1a1\",\"tab.unfocusedInactiveModifiedBorder\":\"#93a1a1\",\"badge.background\":\"#2aa298\",\"badge.foreground\":\"#f0f0f0\",\"button.background\":\"#2aa298\",\"button.foreground\":\"#f0f0f0\",\"button.border\":null,\"button.separator\":\"#f0f0f066\",\"button.hoverBackground\":\"#22827a\",\"button.secondaryBackground\":\"#5f6a79\",\"button.secondaryForeground\":\"#ffffff\",\"button.secondaryHoverBackground\":\"#4c5561\",\"dropdown.background\":\"#f0f0f0\",\"dropdown.foreground\":\"#403f53\",\"dropdown.border\":\"#d9d9d9\",\"list.activeSelectionBackground\":\"#d3e8f8\",\"list.activeSelectionForeground\":\"#403f53\",\"tree.indentGuidesStroke\":\"#a9a9a9\",\"input.background\":\"#f0f0f0\",\"input.foreground\":\"#403f53\",\"input.placeholderForeground\":\"#93a1a1\",\"inputOption.activeBorder\":\"#2aa298\",\"inputOption.hoverBackground\":\"#b8b8b850\",\"inputOption.activeBackground\":\"#93a1a133\",\"inputOption.activeForeground\":\"#000000\",\"inputValidation.infoBackground\":\"#f0f0f0\",\"inputValidation.infoBorder\":\"#d0d0d0\",\"inputValidation.warningBackground\":\"#daaa01\",\"inputValidation.warningBorder\":\"#e0af02\",\"inputValidation.errorBackground\":\"#f76e6e\",\"inputValidation.errorBorder\":\"#de3d3b\",\"keybindingLabel.background\":\"#dddddd66\",\"keybindingLabel.foreground\":\"#555555\",\"keybindingLabel.border\":\"#cccccc66\",\"keybindingLabel.bottomBorder\":\"#bbbbbb66\",\"menu.foreground\":\"#403f53\",\"menu.background\":\"#f0f0f0\",\"menu.selectionForeground\":\"#403f53\",\"menu.selectionBackground\":\"#d3e8f8\",\"menu.separatorBackground\":\"#d4d4d4\",\"editor.snippetTabstopHighlightBackground\":\"#0a326433\",\"editor.snippetFinalTabstopHighlightBorder\":\"#0a326480\",\"terminal.ansiBlack\":\"#403f53\",\"terminal.ansiRed\":\"#de3d3b\",\"terminal.ansiGreen\":\"#08916a\",\"terminal.ansiYellow\":\"#e0af02\",\"terminal.ansiBlue\":\"#288ed7\",\"terminal.ansiMagenta\":\"#d6438a\",\"terminal.ansiCyan\":\"#2aa298\",\"terminal.ansiWhite\":\"#f0f0f0\",\"terminal.ansiBrightBlack\":\"#403f53\",\"terminal.ansiBrightRed\":\"#de3d3b\",\"terminal.ansiBrightGreen\":\"#08916a\",\"terminal.ansiBrightYellow\":\"#daaa01\",\"terminal.ansiBrightBlue\":\"#288ed7\",\"terminal.ansiBrightMagenta\":\"#d6438a\",\"terminal.ansiBrightCyan\":\"#2aa298\",\"terminal.ansiBrightWhite\":\"#f0f0f0\",\"selection.background\":\"#7a8181ad\",\"notifications.background\":\"#f0f0f0\",\"notifications.foreground\":\"#403f53\",\"notificationLink.foreground\":\"#994cc3\",\"notifications.border\":\"#cccccc\",\"notificationCenter.border\":\"#cccccc\",\"notificationToast.border\":\"#cccccc\",\"notificationCenterHeader.foreground\":\"#403f53\",\"notificationCenterHeader.background\":\"#f0f0f0\",\"input.border\":\"#d9d9d9\",\"progressBar.background\":\"#2aa298\",\"list.inactiveSelectionBackground\":\"#e0e7ea\",\"list.inactiveSelectionForeground\":\"#403f53\",\"list.focusBackground\":\"#d3e8f8\",\"list.hoverBackground\":\"#d3e8f8\",\"list.focusForeground\":\"#403f53\",\"list.hoverForeground\":\"#403f53\",\"list.highlightForeground\":\"#403f53\",\"list.errorForeground\":\"#e64d49\",\"list.warningForeground\":\"#daaa01\",\"activityBar.background\":\"#f0f0f0\",\"activityBar.foreground\":\"#403f53\",\"activityBar.dropBackground\":\"#d0d0d0\",\"activityBarBadge.background\":\"#403f53\",\"activityBarBadge.foreground\":\"#f0f0f0\",\"activityBar.border\":\"#f0f0f0\",\"sideBar.background\":\"#f0f0f0\",\"sideBar.foreground\":\"#403f53\",\"sideBarTitle.foreground\":\"#403f53\",\"sideBar.border\":\"#f0f0f0\",\"editorGroup.background\":\"#f6f6f6\",\"editorCursor.foreground\":\"#90a7b2\",\"editor.wordHighlightBackground\":\"#339cec33\",\"editor.wordHighlightStrongBackground\":\"#007dd659\",\"editor.lineHighlightBackground\":\"#f0f0f0\",\"editor.rangeHighlightBackground\":\"#7497a633\",\"editorWhitespace.foreground\":\"#d9d9d9\",\"editorIndentGuide.background\":\"#d9d9d9\",\"editorCodeLens.foreground\":\"#403f53\",\"editorBracketMatch.background\":\"#d3e8f8\",\"editorBracketMatch.border\":\"#2aa298\",\"editorError.border\":\"#fbfbfb\",\"editorWarning.border\":\"#daaa01\",\"editorGutter.addedBackground\":\"#49d0c5\",\"editorGutter.modifiedBackground\":\"#6fbef6\",\"editorGutter.deletedBackground\":\"#f76e6e\",\"editorRuler.foreground\":\"#d9d9d9\",\"editorOverviewRuler.errorForeground\":\"#e64d49\",\"editorOverviewRuler.warningForeground\":\"#daaa01\",\"editorSuggestWidget.background\":\"#f0f0f0\",\"editorSuggestWidget.foreground\":\"#403f53\",\"editorSuggestWidget.highlightForeground\":\"#403f53\",\"editorSuggestWidget.selectedBackground\":\"#d3e8f8\",\"editorSuggestWidget.border\":\"#d9d9d9\",\"debugExceptionWidget.background\":\"#f0f0f0\",\"debugExceptionWidget.border\":\"#d9d9d9\",\"editorMarkerNavigation.background\":\"#d0d0d0\",\"editorMarkerNavigationError.background\":\"#f76e6e\",\"editorMarkerNavigationWarning.background\":\"#daaa01\",\"debugToolBar.background\":\"#f0f0f0\",\"extensionButton.prominentBackground\":\"#2aa298\",\"extensionButton.prominentForeground\":\"#f0f0f0\",\"statusBar.background\":\"#f0f0f0\",\"statusBar.border\":\"#f0f0f0\",\"statusBar.debuggingBackground\":\"#f0f0f0\",\"statusBar.debuggingForeground\":\"#403f53\",\"statusBar.foreground\":\"#403f53\",\"statusBar.noFolderBackground\":\"#f0f0f0\",\"statusBar.noFolderForeground\":\"#403f53\",\"peekView.border\":\"#d9d9d9\",\"peekViewEditor.background\":\"#f6f6f6\",\"peekViewEditorGutter.background\":\"#f6f6f6\",\"peekViewEditor.matchHighlightBackground\":\"#49d0c5\",\"peekViewResult.background\":\"#f0f0f0\",\"peekViewResult.fileForeground\":\"#403f53\",\"peekViewResult.lineForeground\":\"#403f53\",\"peekViewResult.matchHighlightBackground\":\"#49d0c5\",\"peekViewResult.selectionBackground\":\"#e0e7ea\",\"peekViewResult.selectionForeground\":\"#403f53\",\"peekViewTitle.background\":\"#f0f0f0\",\"peekViewTitleLabel.foreground\":\"#403f53\",\"peekViewTitleDescription.foreground\":\"#403f53\",\"terminal.foreground\":\"#403f53\"},\"fg\":\"#403f53\",\"bg\":\"#f6f7f9\",\"semanticHighlighting\":false,\"settings\":[{\"name\":\"Changed\",\"scope\":[\"markup.changed\",\"meta.diff.header.git\",\"meta.diff.header.from-file\",\"meta.diff.header.to-file\"],\"settings\":{\"foreground\":\"#556484\"}},{\"name\":\"Deleted\",\"scope\":[\"markup.deleted.diff\"],\"settings\":{\"foreground\":\"#ae3c3afd\"}},{\"name\":\"Inserted\",\"scope\":[\"markup.inserted.diff\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Global settings\",\"settings\":{\"background\":\"#011627\",\"foreground\":\"#403f53\"}},{\"name\":\"Comment\",\"scope\":[\"comment\"],\"settings\":{\"foreground\":\"#5f636f\"}},{\"name\":\"String\",\"scope\":[\"string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"String Quoted\",\"scope\":[\"string.quoted\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Support Constant Math\",\"scope\":[\"support.constant.math\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Number\",\"scope\":[\"constant.numeric\",\"constant.character.numeric\"],\"settings\":{\"foreground\":\"#aa0982\",\"fontStyle\":\"\"}},{\"name\":\"Built-in constant\",\"scope\":[\"constant.language\",\"punctuation.definition.constant\",\"variable.other.constant\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"User-defined constant\",\"scope\":[\"constant.character\",\"constant.other\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Constant Character Escape\",\"scope\":[\"constant.character.escape\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"RegExp String\",\"scope\":[\"string.regexp\",\"string.regexp keyword.other\"],\"settings\":{\"foreground\":\"#3a688f\"}},{\"name\":\"Comma in functions\",\"scope\":[\"meta.function punctuation.separator.comma\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"Variable\",\"scope\":[\"variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Keyword\",\"scope\":[\"punctuation.accessor\",\"keyword\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage\",\"scope\":[\"storage\",\"meta.var.expr\",\"meta.class meta.method.declaration meta.var.expr storage.type.js\",\"storage.type.property.js\",\"storage.type.property.ts\",\"storage.type.property.tsx\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type.function.arrow.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Class name\",\"scope\":[\"entity.name.class\",\"meta.class entity.name.type.class\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Inherited class\",\"scope\":[\"entity.other.inherited-class\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Function name\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Meta Tag\",\"scope\":[\"punctuation.definition.tag\",\"meta.tag\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"HTML Tag names\",\"scope\":[\"entity.name.tag\",\"meta.tag.other.html\",\"meta.tag.other.js\",\"meta.tag.other.tsx\",\"entity.name.tag.tsx\",\"entity.name.tag.js\",\"entity.name.tag\",\"meta.tag.js\",\"meta.tag.tsx\",\"meta.tag.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Tag attribute\",\"scope\":[\"entity.other.attribute-name\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Entity Name Tag Custom\",\"scope\":[\"entity.name.tag.custom\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Library (function & constant)\",\"scope\":[\"support.function\",\"support.constant\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Support Constant Property Value meta\",\"scope\":[\"support.constant.meta.property-value\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Library class/type\",\"scope\":[\"support.type\",\"support.class\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Support Variable DOM\",\"scope\":[\"support.variable.dom\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Invalid\",\"scope\":[\"invalid\"],\"settings\":{\"foreground\":\"#bb2060\"}},{\"name\":\"Invalid deprecated\",\"scope\":[\"invalid.deprecated\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Keyword Operator\",\"scope\":[\"keyword.operator\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Relational\",\"scope\":[\"keyword.operator.relational\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Assignment\",\"scope\":[\"keyword.operator.assignment\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Arithmetic\",\"scope\":[\"keyword.operator.arithmetic\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Bitwise\",\"scope\":[\"keyword.operator.bitwise\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Increment\",\"scope\":[\"keyword.operator.increment\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Ternary\",\"scope\":[\"keyword.operator.ternary\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Double-Slashed Comment\",\"scope\":[\"comment.line.double-slash\"],\"settings\":{\"foreground\":\"#5d6376\"}},{\"name\":\"Object\",\"scope\":[\"object\"],\"settings\":{\"foreground\":\"#58656a\"}},{\"name\":\"Null\",\"scope\":[\"constant.language.null\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Meta Brace\",\"scope\":[\"meta.brace\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Meta Delimiter Period\",\"scope\":[\"meta.delimiter.period\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Punctuation Definition String\",\"scope\":[\"punctuation.definition.string\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Punctuation Definition String Markdown\",\"scope\":[\"punctuation.definition.string.begin.markdown\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Boolean\",\"scope\":[\"constant.language.boolean\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Object Comma\",\"scope\":[\"object.comma\"],\"settings\":{\"foreground\":\"#646464\"}},{\"name\":\"Variable Parameter Function\",\"scope\":[\"variable.parameter.function\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Support Type Property Name & entity name tags\",\"scope\":[\"support.type.vendor.property-name\",\"support.constant.vendor.property-value\",\"support.type.property-name\",\"meta.property-list entity.name.tag\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Entity Name tag reference in stylesheets\",\"scope\":[\"meta.property-list entity.name.tag.reference\"],\"settings\":{\"foreground\":\"#286d70\"}},{\"name\":\"Constant Other Color RGB Value Punctuation Definition Constant\",\"scope\":[\"constant.other.color.rgb-value punctuation.definition.constant\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Constant Other Color\",\"scope\":[\"constant.other.color\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Keyword Other Unit\",\"scope\":[\"keyword.other.unit\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Meta Selector\",\"scope\":[\"meta.selector\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Entity Other Attribute Name Id\",\"scope\":[\"entity.other.attribute-name.id\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Meta Property Name\",\"scope\":[\"meta.property-name\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Doctypes\",\"scope\":[\"entity.name.tag.doctype\",\"meta.tag.sgml.doctype\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Punctuation Definition Parameters\",\"scope\":[\"punctuation.definition.parameters\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Keyword Control Operator\",\"scope\":[\"keyword.control.operator\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Keyword Operator Logical\",\"scope\":[\"keyword.operator.logical\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"\"}},{\"name\":\"Variable Instances\",\"scope\":[\"variable.instance\",\"variable.other.instance\",\"variable.readwrite.instance\",\"variable.other.readwrite.instance\",\"variable.other.property\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Variable Property Other object property\",\"scope\":[\"variable.other.object.property\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Variable Property Other object\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Entity Name Function\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Keyword Operator Comparison, imports, returns and Keyword Operator Ruby\",\"scope\":[\"keyword.operator.comparison\",\"keyword.control.flow.js\",\"keyword.control.flow.ts\",\"keyword.control.flow.tsx\",\"keyword.control.ruby\",\"keyword.control.module.ruby\",\"keyword.control.class.ruby\",\"keyword.control.def.ruby\",\"keyword.control.loop.js\",\"keyword.control.loop.ts\",\"keyword.control.import.js\",\"keyword.control.import.ts\",\"keyword.control.import.tsx\",\"keyword.control.from.js\",\"keyword.control.from.ts\",\"keyword.control.from.tsx\",\"keyword.operator.instanceof.js\",\"keyword.operator.expression.instanceof.ts\",\"keyword.operator.expression.instanceof.tsx\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Control Conditional\",\"scope\":[\"keyword.control.conditional.js\",\"keyword.control.conditional.ts\",\"keyword.control.switch.js\",\"keyword.control.switch.ts\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"\"}},{\"name\":\"Support Constant, `new` keyword, Special Method Keyword, `debugger`, other keywords\",\"scope\":[\"support.constant\",\"keyword.other.special-method\",\"keyword.other.new\",\"keyword.other.debugger\",\"keyword.control\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Support Function\",\"scope\":[\"support.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Invalid Broken\",\"scope\":[\"invalid.broken\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Invalid Unimplemented\",\"scope\":[\"invalid.unimplemented\"],\"settings\":{\"foreground\":\"#486e26\"}},{\"name\":\"Invalid Illegal\",\"scope\":[\"invalid.illegal\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Language Variable\",\"scope\":[\"variable.language\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Support Variable Property\",\"scope\":[\"support.variable.property\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Variable Function\",\"scope\":[\"variable.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variable Interpolation\",\"scope\":[\"variable.interpolation\"],\"settings\":{\"foreground\":\"#a64348\"}},{\"name\":\"Meta Function Call\",\"scope\":[\"meta.function-call\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Punctuation Section Embedded\",\"scope\":[\"punctuation.section.embedded\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Punctuation Tweaks\",\"scope\":[\"punctuation.terminator.expression\",\"punctuation.definition.arguments\",\"punctuation.definition.array\",\"punctuation.section.array\",\"meta.array\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"More Punctuation Tweaks\",\"scope\":[\"punctuation.definition.list.begin\",\"punctuation.definition.list.end\",\"punctuation.separator.arguments\",\"punctuation.definition.list\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Template Strings\",\"scope\":[\"string.template meta.template.expression\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Backticks(``) in Template Strings\",\"scope\":[\"string.template punctuation.definition.string\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Italics\",\"scope\":[\"italic\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"italic\"}},{\"name\":\"Bold\",\"scope\":[\"bold\"],\"settings\":{\"foreground\":\"#3b61b0\",\"fontStyle\":\"bold\"}},{\"name\":\"Quote\",\"scope\":[\"quote\"],\"settings\":{\"foreground\":\"#5c6285\"}},{\"name\":\"Raw Code\",\"scope\":[\"raw\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"CoffeeScript Variable Assignment\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#186e73\"}},{\"name\":\"CoffeeScript Parameter Function\",\"scope\":[\"variable.parameter.function.coffee\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"CoffeeScript Assignments\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"C# Readwrite Variables\",\"scope\":[\"variable.other.readwrite.cs\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"C# Classes & Storage types\",\"scope\":[\"entity.name.type.class.cs\",\"storage.type.cs\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"C# Namespaces\",\"scope\":[\"entity.name.type.namespace.cs\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Tag names in Stylesheets\",\"scope\":[\"entity.name.tag.css\",\"entity.name.tag.less\",\"entity.name.tag.custom.css\",\"support.constant.property-value.css\"],\"settings\":{\"foreground\":\"#984e4d\",\"fontStyle\":\"\"}},{\"name\":\"Wildcard(*) selector in Stylesheets\",\"scope\":[\"entity.name.tag.wildcard.css\",\"entity.name.tag.wildcard.less\",\"entity.name.tag.wildcard.scss\",\"entity.name.tag.wildcard.sass\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"CSS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Attribute Name for CSS\",\"scope\":[\"meta.attribute-selector.css entity.other.attribute-name.attribute\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Elixir Classes\",\"scope\":[\"source.elixir support.type.elixir\",\"source.elixir meta.module.elixir entity.name.class.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Functions\",\"scope\":[\"source.elixir entity.name.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Constants\",\"scope\":[\"source.elixir constant.other.symbol.elixir\",\"source.elixir constant.other.keywords.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir String Punctuations\",\"scope\":[\"source.elixir punctuation.definition.string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir\",\"scope\":[\"source.elixir variable.other.readwrite.module.elixir\",\"source.elixir variable.other.readwrite.module.elixir punctuation.definition.variable.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Binary Punctuations\",\"scope\":[\"source.elixir .punctuation.binary.elixir\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Closure Constant Keyword\",\"scope\":[\"constant.keyword.clojure\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Go Function Calls\",\"scope\":[\"source.go meta.function-call.go\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Go Keywords\",\"scope\":[\"source.go keyword.package.go\",\"source.go keyword.import.go\",\"source.go keyword.function.go\",\"source.go keyword.type.go\",\"source.go keyword.struct.go\",\"source.go keyword.interface.go\",\"source.go keyword.const.go\",\"source.go keyword.var.go\",\"source.go keyword.map.go\",\"source.go keyword.channel.go\",\"source.go keyword.control.go\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Go Constants e.g. nil, string format (%s, %d, etc.)\",\"scope\":[\"source.go constant.language.go\",\"source.go constant.other.placeholder.go\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"C++ Functions\",\"scope\":[\"entity.name.function.preprocessor.cpp\",\"entity.scope.name.cpp\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"C++ Meta Namespace\",\"scope\":[\"meta.namespace-block.cpp\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"C++ Language Primitive Storage\",\"scope\":[\"storage.type.language.primitive.cpp\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"C++ Preprocessor Macro\",\"scope\":[\"meta.preprocessor.macro.cpp\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"C++ Variable Parameter\",\"scope\":[\"variable.parameter\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Powershell Variables\",\"scope\":[\"variable.other.readwrite.powershell\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Powershell Function\",\"scope\":[\"support.function.powershell\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"ID Attribute Name in HTML\",\"scope\":[\"entity.other.attribute-name.id.html\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"HTML Punctuation Definition Tag\",\"scope\":[\"punctuation.definition.tag.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"HTML Doctype\",\"scope\":[\"meta.tag.sgml.doctype.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"JavaScript Classes\",\"scope\":[\"meta.class entity.name.type.class.js\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"JavaScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.js\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"JavaScript Terminator\",\"scope\":[\"terminator.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Meta Punctuation Definition\",\"scope\":[\"meta.js punctuation.definition.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Entity Names in Code Documentations\",\"scope\":[\"entity.name.type.instance.jsdoc\",\"entity.name.type.instance.phpdoc\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"Other Variables in Code Documentations\",\"scope\":[\"variable.other.jsdoc\",\"variable.other.phpdoc\"],\"settings\":{\"foreground\":\"#3e697c\"}},{\"name\":\"JavaScript module imports and exports\",\"scope\":[\"variable.other.meta.import.js\",\"meta.import.js variable.other\",\"variable.other.meta.export.js\",\"meta.export.js variable.other\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Variable Parameter Function\",\"scope\":[\"variable.parameter.function.js\"],\"settings\":{\"foreground\":\"#555ea2\"}},{\"name\":\"JavaScript[React] Variable Other Object\",\"scope\":[\"variable.other.object.js\",\"variable.other.object.jsx\",\"variable.object.property.js\",\"variable.object.property.jsx\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Variables\",\"scope\":[\"variable.js\",\"variable.other.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Entity Name Type\",\"scope\":[\"entity.name.type.js\",\"entity.name.type.module.js\"],\"settings\":{\"foreground\":\"#111111\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Support Classes\",\"scope\":[\"support.class.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JSON Property Names\",\"scope\":[\"support.type.property-name.json\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"JSON Support Constants\",\"scope\":[\"support.constant.json\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"JSON Property values (string)\",\"scope\":[\"meta.structure.dictionary.value.json string.quoted.double\"],\"settings\":{\"foreground\":\"#7c5686\"}},{\"name\":\"Strings in JSON values\",\"scope\":[\"string.quoted.double.json punctuation.definition.string.json\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Specific JSON Property values like null\",\"scope\":[\"meta.structure.dictionary.json meta.structure.dictionary.value constant.language\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"JavaScript Other Variable\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Ruby Variables\",\"scope\":[\"variable.other.ruby\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Ruby Class\",\"scope\":[\"entity.name.type.class.ruby\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Ruby Hashkeys\",\"scope\":[\"constant.language.symbol.hashkey.ruby\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Ruby Symbols\",\"scope\":[\"constant.language.symbol.ruby\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"LESS Tag names\",\"scope\":[\"entity.name.tag.less\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"LESS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Attribute Name for LESS\",\"scope\":[\"meta.attribute-selector.less entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Markdown Headings\",\"scope\":[\"markup.heading.markdown\",\"markup.heading.setext.1.markdown\",\"markup.heading.setext.2.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown Italics\",\"scope\":[\"markup.italic.markdown\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"italic\"}},{\"name\":\"Markdown Bold\",\"scope\":[\"markup.bold.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\",\"fontStyle\":\"bold\"}},{\"name\":\"Markdown Quote + others\",\"scope\":[\"markup.quote.markdown\"],\"settings\":{\"foreground\":\"#5c6285\"}},{\"name\":\"Markdown Raw Code + others\",\"scope\":[\"markup.inline.raw.markdown\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Markdown Links\",\"scope\":[\"markup.underline.link.markdown\",\"markup.underline.link.image.markdown\"],\"settings\":{\"foreground\":\"#954f5a\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Link Title and Description\",\"scope\":[\"string.other.link.title.markdown\",\"string.other.link.description.markdown\"],\"settings\":{\"foreground\":\"#403f53\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Punctuation\",\"scope\":[\"punctuation.definition.string.markdown\",\"punctuation.definition.string.begin.markdown\",\"punctuation.definition.string.end.markdown\",\"meta.link.inline.markdown punctuation.definition.string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown MetaData Punctuation\",\"scope\":[\"punctuation.definition.metadata.markdown\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Markdown List Punctuation\",\"scope\":[\"beginning.punctuation.definition.list.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown Inline Raw String\",\"scope\":[\"markup.inline.raw.string.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"PHP Variables\",\"scope\":[\"variable.other.php\",\"variable.other.property.php\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Support Classes in PHP\",\"scope\":[\"support.class.php\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Punctuations in PHP function calls\",\"scope\":[\"meta.function-call.php punctuation\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"PHP Global Variables\",\"scope\":[\"variable.other.global.php\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Declaration Punctuation in PHP Global Variables\",\"scope\":[\"variable.other.global.php punctuation.definition.variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Language Constants in Python\",\"scope\":[\"constant.language.python\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Python Function Parameter and Arguments\",\"scope\":[\"variable.parameter.function.python\",\"meta.function-call.arguments.python\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Python Function Call\",\"scope\":[\"meta.function-call.python\",\"meta.function-call.generic.python\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Punctuations in Python\",\"scope\":[\"punctuation.python\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Decorator Functions in Python\",\"scope\":[\"entity.name.function.decorator.python\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Python Language Variable\",\"scope\":[\"source.python variable.language.special\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Python import control keyword\",\"scope\":[\"keyword.control\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"SCSS Variable\",\"scope\":[\"variable.scss\",\"variable.sass\",\"variable.parameter.url.scss\",\"variable.parameter.url.sass\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Attribute Name for SASS\",\"scope\":[\"meta.attribute-selector.scss entity.other.attribute-name.attribute\",\"meta.attribute-selector.sass entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Tag names in SASS\",\"scope\":[\"entity.name.tag.scss\",\"entity.name.tag.sass\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"SASS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.scss\",\"keyword.other.unit.sass\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"TypeScript[React] Variables and Object Properties\",\"scope\":[\"variable.other.readwrite.alias.ts\",\"variable.other.readwrite.alias.tsx\",\"variable.other.readwrite.ts\",\"variable.other.readwrite.tsx\",\"variable.other.object.ts\",\"variable.other.object.tsx\",\"variable.object.property.ts\",\"variable.object.property.tsx\",\"variable.other.ts\",\"variable.other.tsx\",\"variable.tsx\",\"variable.ts\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript[React] Entity Name Types\",\"scope\":[\"entity.name.type.ts\",\"entity.name.type.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript[React] Node Classes\",\"scope\":[\"support.class.node.ts\",\"support.class.node.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"TypeScript[React] Entity Name Types as Parameters\",\"scope\":[\"meta.type.parameters.ts entity.name.type\",\"meta.type.parameters.tsx entity.name.type\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"TypeScript[React] Import/Export Punctuations\",\"scope\":[\"meta.import.ts punctuation.definition.block\",\"meta.import.tsx punctuation.definition.block\",\"meta.export.ts punctuation.definition.block\",\"meta.export.tsx punctuation.definition.block\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.decorator punctuation.decorator.ts\",\"meta.decorator punctuation.decorator.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.tag.js meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"YAML Entity Name Tags\",\"scope\":[\"entity.name.tag.yaml\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"JavaScript Variable Other ReadWrite\",\"scope\":[\"variable.other.readwrite.js\",\"variable.parameter\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Support Class Component\",\"scope\":[\"support.class.component.js\",\"support.class.component.tsx\"],\"settings\":{\"foreground\":\"#aa0982\",\"fontStyle\":\"\"}},{\"name\":\"Text nested in React tags\",\"scope\":[\"meta.jsx.children\",\"meta.jsx.children.js\",\"meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript Classes\",\"scope\":[\"meta.class entity.name.type.class.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript Entity Name Type\",\"scope\":[\"entity.name.type.tsx\",\"entity.name.type.module.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript Class Variable Keyword\",\"scope\":[\"meta.class.ts meta.var.expr.ts storage.type.ts\",\"meta.class.tsx meta.var.expr.tsx storage.type.tsx\"],\"settings\":{\"foreground\":\"#76578b\"}},{\"name\":\"TypeScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.ts\",\"meta.method.declaration storage.type.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"normalize font style of certain components\",\"scope\":[\"meta.property-list.css meta.property-value.css variable.other.less\",\"meta.property-list.scss variable.scss\",\"meta.property-list.sass variable.sass\",\"meta.brace\",\"keyword.operator.operator\",\"keyword.operator.or.regexp\",\"keyword.operator.expression.in\",\"keyword.operator.relational\",\"keyword.operator.assignment\",\"keyword.operator.comparison\",\"keyword.operator.type\",\"keyword.operator\",\"keyword\",\"punctuation.definition.string\",\"punctuation\",\"variable.other.readwrite.js\",\"storage.type\",\"source.css\",\"string.quoted\"],\"settings\":{\"fontStyle\":\"\"}}],\"styleOverrides\":{\"frames\":{\"editorBackground\":\"var(--sl-color-gray-7)\",\"terminalBackground\":\"var(--sl-color-gray-7)\",\"editorActiveTabBackground\":\"var(--sl-color-gray-7)\",\"terminalTitlebarDotsForeground\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"terminalTitlebarDotsOpacity\":\"0.75\",\"inlineButtonForeground\":\"var(--sl-color-text)\",\"frameBoxShadowCssValue\":\"none\"},\"textMarkers\":{\"markBackground\":\"#0000001a\",\"markBorderColor\":\"#00000055\"}}}],\"defaultLocale\":\"en\",\"cascadeLayer\":\"starlight.components\",\"styleOverrides\":{\"borderRadius\":\"0px\",\"borderWidth\":\"1px\",\"codePaddingBlock\":\"0.75rem\",\"codePaddingInline\":\"1rem\",\"codeFontFamily\":\"var(--__sl-font-mono)\",\"codeFontSize\":\"var(--sl-text-code)\",\"codeLineHeight\":\"var(--sl-line-height)\",\"uiFontFamily\":\"var(--__sl-font)\",\"textMarkers\":{\"lineDiffIndicatorMarginLeft\":\"0.25rem\",\"defaultChroma\":\"45\",\"backgroundOpacity\":\"60%\"}},\"plugins\":[{\"name\":\"Starlight Plugin\",\"hooks\":{}},{\"name\":\"astro-expressive-code\",\"hooks\":{}}]}]],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"prefetch\":{\"prefetchAll\":true},\"i18n\":{\"defaultLocale\":\"en\",\"locales\":[\"en\"],\"routing\":{\"prefixDefaultLocale\":false,\"redirectToDefaultLocale\":false,\"fallbackType\":\"redirect\"}}}","docs",["Map",11,12,27,28,42,43],"index",{"id":11,"data":13,"filePath":25,"digest":26,"deferredRender":16},{"title":14,"description":15,"editUrl":16,"head":17,"template":18,"hero":19,"sidebar":22,"pagefind":16,"draft":23},"Starter Tutorial","Get started building a tutorial.",true,[],"splash",{"tagline":20,"actions":21},"Starting a tutorial!",[],{"hidden":23,"attrs":24},false,{},"src/content/docs/index.mdx","369e120ef938088e","reference",{"id":27,"data":29,"body":39,"filePath":40,"digest":41,"deferredRender":16},{"title":30,"description":31,"editUrl":16,"head":32,"template":33,"hero":34,"sidebar":37,"pagefind":16,"draft":23},"Reference","Get started building your docs site with Starlight.",[],"doc",{"tagline":35,"actions":36},"Components that can be used in Tutorials",[],{"hidden":23,"attrs":38},{},"## Asides\n\n{% aside %}\nNote aside.\n{% /aside %}\n\n```\n{% aside %}\nNote aside.\n{% /aside %}\n```\n\n{% aside type=\"caution\" %}\nCaution aside.\n{% /aside %}\n\n```\n{% aside type=\"caution\" %}\nCaution aside.\n{% /aside %}\n```\n\n{% aside type=\"tip\" %}\nTip aside.\n{% /aside %}\n\n```\n{% aside type=\"tip\" %}\nTip aside.\n{% /aside %}\n```\n\n{% aside type=\"danger\" %}\nDanger aside.\n{% /aside %}\n\n```\n{% aside type=\"danger\" %}\nDanger aside.\n{% /aside %}\n```\n\n## Blockquotes\n\n> This is a blockquote.\n>\n> This is another line in the blockquote.\n\n## Code Blocks\n\n```js\nfunction demo() {\n // This is a comment\n console.log('Hello World');\n}\n```\n\n## File Trees\n\n{% filetree %}\n\n- astro.config.mjs\n- package.json\n- src\n - components\n - Header.astro\n - Title.astro\n - pages/\n\n{% /filetree %}\n\n```\n{% filetree %}\n\n- astro.config.mjs\n- package.json\n- src\n - components\n - Header.astro\n - Title.astro\n - pages/\n\n{% /filetree %}\n```\n\n## Steps\n\n{% steps %}\n\n1. Import the component into your MDX file:\n\n ```js\n import { Steps } from '@astrojs/starlight/components';\n ```\n\n2. Wrap `\u003CSteps>` around your ordered list items.\n\n{% /steps %}\n\n```\n {% steps %}\n\n 1. Import the component into your MDX file:\n\n ```js\n import { Steps } from '@astrojs/starlight/components';\n ```\n\n 2. Wrap `\u003CSteps>` around your ordered list items.\n\n {% /steps %}\n```\n\n## Tabs\n\n{% tabs %}\n{% tabitem label=\"Tab 1\" %}\nTab 1 text\n{% /tabitem %}\n\n{% tabitem label=\"Tab 2\" %}\nTab 2 text\n{% /tabitem %}\n{% /tabs %}\n\n```\n{% tabs %}\n{% tabitem label=\"Tab 1\" %}\nTab 1 text\n{% /tabitem %}\n\n{% tabitem label=\"Tab 2\" %}\nTab 2 text\n{% /tabitem %}\n{% /tabs %}\n```","src/content/docs/reference.mdoc","17bf736636d92cae","01_first_step",{"id":42,"data":44,"body":50,"filePath":51,"digest":52,"rendered":53},{"title":45,"description":46,"editUrl":16,"head":47,"template":33,"sidebar":48,"pagefind":16,"draft":23},"Introduction","An introduction to the onboarding tutorial",[],{"hidden":23,"attrs":49},{},"# Starter Tutorial\n\nThis is a paragraph about what the tutorial contains.\n\nHere is some code:\n\n```js\nfunction demo() {\n // This is a comment\n console.log('Hello World');\n}\n```","src/content/docs/01_First_Step.md","5f8f5e057d466cd2",{"html":54,"metadata":55},"\u003Cdiv class=\"sl-heading-wrapper level-h1\">\u003Ch1 id=\"starter-tutorial\">Starter Tutorial\u003C/h1>\u003Ca class=\"sl-anchor-link\" href=\"#starter-tutorial\">\u003Cspan aria-hidden=\"true\" class=\"sl-anchor-icon\">\u003Csvg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\">\u003Cpath fill=\"currentcolor\" d=\"m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z\">\u003C/path>\u003C/svg>\u003C/span>\u003Cspan class=\"sr-only\" data-pagefind-ignore=\"\">Section titled “Starter Tutorial”\u003C/span>\u003C/a>\u003C/div>\n\u003Cp>This is a paragraph about what the tutorial contains.\u003C/p>\n\u003Cp>Here is some code:\u003C/p>\n\u003Cdiv class=\"expressive-code\">\u003Clink rel=\"stylesheet\" href=\"/_astro/ec.v4551.css\">\u003Cscript type=\"module\" src=\"/_astro/ec.0vx5m.js\">\u003C/script>\u003Cfigure class=\"frame not-content\">\u003Cfigcaption class=\"header\">\u003C/figcaption>\u003Cpre data-language=\"js\">\u003Ccode>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan style=\"--0:#C792EA;--1:#8844AE\">function\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\"> \u003C/span>\u003Cspan style=\"--0:#82AAFF;--1:#3B61B0\">demo\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">()\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\"> {\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan class=\"indent\"> \u003C/span>\u003Cspan style=\"--0:#919F9F;--1:#5D6376\">// This is a comment\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan class=\"indent\"> \u003C/span>\u003Cspan style=\"--0:#7FDBCA;--1:#096E72\">console\u003C/span>\u003Cspan style=\"--0:#C792EA;--1:#8844AE\">.\u003C/span>\u003Cspan style=\"--0:#82AAFF;--1:#3B61B0\">log\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">(\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">'\u003C/span>\u003Cspan style=\"--0:#ECC48D;--1:#984E4D\">Hello World\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">'\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">);\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">}\u003C/span>\u003C/div>\u003C/div>\u003C/code>\u003C/pre>\u003Cdiv class=\"copy\">\u003Cdiv aria-live=\"polite\">\u003C/div>\u003Cbutton title=\"Copy to clipboard\" data-copied=\"Copied!\" data-code=\"function demo() { // This is a comment console.log('Hello World');}\">\u003Cdiv>\u003C/div>\u003C/button>\u003C/div>\u003C/figure>\u003C/div>",{"headings":56,"localImagePaths":60,"remoteImagePaths":61,"frontmatter":62,"imagePaths":63},[57],{"depth":58,"slug":59,"text":14},1,"starter-tutorial",[],[],{"title":45,"description":46},[]] \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.astro/settings.json b/sources/vonage_video_ios_app-theme-customization/.astro/settings.json new file mode 100644 index 00000000..6945ae7d --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1785852561838 + } +} \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.astro/types.d.ts b/sources/vonage_video_ios_app-theme-customization/.astro/types.d.ts new file mode 100644 index 00000000..03d7cc43 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.astro/types.d.ts @@ -0,0 +1,2 @@ +/// +/// \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.vscode/extensions.json b/sources/vonage_video_ios_app-theme-customization/.vscode/extensions.json new file mode 100644 index 00000000..de4e2f35 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "astro-build.astro-vscode", + "ms-vscode.live-server" + ] +} \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/.vscode/tasks.json b/sources/vonage_video_ios_app-theme-customization/.vscode/tasks.json new file mode 100644 index 00000000..ee6b95ff --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Tutorial Editor", + "type": "shell", + "command": "npm install && npm run dev", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/README.md b/sources/vonage_video_ios_app-theme-customization/README.md new file mode 100644 index 00000000..0a78c808 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/README.md @@ -0,0 +1,9 @@ +# Astro Dev Toolbar application for Vonage Onboarding Tutorials + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/fork/github/Vonage-Community/tutorial-interactive_tutorials/tree/main/toolbar-app/) + +or if working locally, run `npm install && npm run dev` + +For more information on the Astro Dev Toolbar, please see their [documentation](https://docs.astro.build/en/guides/dev-toolbar/) + +[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) diff --git a/sources/vonage_video_ios_app-theme-customization/astro.config.mjs b/sources/vonage_video_ios_app-theme-customization/astro.config.mjs new file mode 100644 index 00000000..8f4ad034 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import relativeLinks from 'astro-relative-links'; +import vonageIntegration from './vonage-toolbar/integration.ts'; + +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + relativeLinks(), + vonageIntegration, + starlight({ + title: 'Vonage Coding Exercise', + tableOfContents: true, + pagefind: false, + }), + markdoc(), + ], +}); diff --git a/sources/vonage_video_ios_app-theme-customization/download-toolbar.js b/sources/vonage_video_ios_app-theme-customization/download-toolbar.js new file mode 100644 index 00000000..34c88a09 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/download-toolbar.js @@ -0,0 +1,40 @@ +import fs from 'fs/promises'; + +async function downloadFile(url, filename) { + console.log('downloading: ', filename); + try { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + + await fs.mkdir('./vonage-toolbar', { recursive: true }); + + await fs.writeFile( + `./vonage-toolbar/${filename}`, + Buffer.from(buffer) + ); + + console.log(`✅ File ${filename} downloaded and written successfully!`); + + } catch (error) { + console.error(`❌ Error downloading/writing ${filename}:`, error); + } +} + +async function main() { + console.log('download toolbar application files'); + + await Promise.all([ + downloadFile( + 'https://raw.githubusercontent.com/vonage-community/tutorial-interactive_tutorials/refs/heads/main/toolbar-app/vonage-toolbar/integration.ts', + 'integration.ts' + ), + downloadFile( + 'https://raw.githubusercontent.com/vonage-community/tutorial-interactive_tutorials/refs/heads/main/toolbar-app/vonage-toolbar/app.ts', + 'app.ts' + ) + ]); + + console.log('🎉 All downloads complete. Starting Astro...'); +} + +main(); diff --git a/sources/vonage_video_ios_app-theme-customization/markdoc.config.mjs b/sources/vonage_video_ios_app-theme-customization/markdoc.config.mjs new file mode 100644 index 00000000..329155ae --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/markdoc.config.mjs @@ -0,0 +1,6 @@ +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import starlightMarkdoc from '@astrojs/starlight-markdoc'; + +export default defineMarkdocConfig({ + extends: [starlightMarkdoc()], +}); diff --git a/sources/vonage_video_ios_app-theme-customization/package-lock.json b/sources/vonage_video_ios_app-theme-customization/package-lock.json new file mode 100644 index 00000000..f5acc680 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/package-lock.json @@ -0,0 +1,7900 @@ +{ + "name": "@example/starlight-basics", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@example/starlight-basics", + "version": "0.0.1", + "dependencies": { + "@astrojs/markdoc": "^0.15.10", + "@astrojs/starlight": "^0.37.6", + "@astrojs/starlight-markdoc": "^0.5.1", + "adm-zip": "^0.5.16", + "astro": "^5.18.1", + "astro-relative-links": "^0.4.2", + "sharp": "^0.33.0" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", + "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", + "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "license": "MIT" + }, + "node_modules/@astrojs/markdoc": { + "version": "0.15.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdoc/-/markdoc-0.15.10.tgz", + "integrity": "sha512-05V4Vy4J14aDpBstKbuyO7ArY+VCF80SElWyx80ddeWkUiOERw4SjGbnzgAI4AQtDdJ+pJdlv/16TMM65+YafA==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/markdown-remark": "6.3.10", + "@astrojs/prism": "3.3.0", + "@markdoc/markdoc": "^0.5.4", + "esbuild": "^0.25.0", + "github-slugger": "^2.0.0", + "htmlparser2": "^10.0.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^5.0.0" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.13.tgz", + "integrity": "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "6.3.10", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.15.0", + "es-module-lexer": "^1.7.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^5.0.0" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.0.tgz", + "integrity": "sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==", + "license": "MIT", + "dependencies": { + "sitemap": "^8.0.2", + "stream-replace-string": "^2.0.0", + "zod": "^3.25.76" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.37.6.tgz", + "integrity": "sha512-wQrKwH431q+8FsLBnNQeG+R36TMtEGxTQ2AuiVpcx9APcazvL3n7wVW8mMmYyxX0POjTnxlcWPkdMGR3Yj1L+w==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "^6.3.1", + "@astrojs/mdx": "^4.2.3", + "@astrojs/sitemap": "^3.3.0", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.41.1", + "bcp-47": "^2.1.0", + "hast-util-from-html": "^2.0.1", + "hast-util-select": "^6.0.2", + "hast-util-to-string": "^3.0.0", + "hastscript": "^9.0.0", + "i18next": "^23.11.5", + "js-yaml": "^4.1.0", + "klona": "^2.0.6", + "magic-string": "^0.30.17", + "mdast-util-directive": "^3.0.0", + "mdast-util-to-markdown": "^2.1.0", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.3.0", + "rehype": "^13.0.1", + "rehype-format": "^5.0.0", + "remark-directive": "^3.0.0", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.2" + }, + "peerDependencies": { + "astro": "^5.5.0" + } + }, + "node_modules/@astrojs/starlight-markdoc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/starlight-markdoc/-/starlight-markdoc-0.5.1.tgz", + "integrity": "sha512-YxTvCHr/0ovDUhuRm0xeCjjGYtnqpa9EtTwc9fTbRO7Zpnsu1mDmm2L9HHSBWZ4YsFb005/GJYNDjk4WXNLoUg==", + "license": "MIT", + "peerDependencies": { + "@astrojs/markdoc": ">=0.12.1", + "@astrojs/starlight": ">=0.35.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.41.7.tgz", + "integrity": "sha512-ck92uZYZ9Wba2zxkiZLsZGi9N54pMSAVdrI9uW3Oo9AtLglD5RmrdTwbYPCT2S/jC36JGB2i+pnQtBm/Ib2+dg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.41.7.tgz", + "integrity": "sha512-diKtxjQw/979cTglRFaMCY/sR6hWF0kSMg8jsKLXaZBSfGS0I/Hoe7Qds3vVEgeoW+GHHQzMcwvgx/MOIXhrTA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.41.7.tgz", + "integrity": "sha512-DL605bLrUOgqTdZ0Ot5MlTaWzppRkzzqzeGEu7ODnHF39IkEBbFdsC7pbl3LbUQ1DFtnfx6rD54k/cdofbW6KQ==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7", + "shiki": "^3.2.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.41.7.tgz", + "integrity": "sha512-Ewpwuc5t6eFdZmWlFyeuy3e1PTQC0jFvw2Q+2bpcWXbOZhPLsT7+h8lsSIJxb5mS7wZko7cKyQ2RLYDyK6Fpmw==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@markdoc/markdoc": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@markdoc/markdoc/-/markdoc-0.5.4.tgz", + "integrity": "sha512-36YFNlqFk//gVNGm5xZaTWVwbAVF2AOmVjf1tiUrS6tCoD/YSkVy2E3CkAfhc5MlKcjparL/QFHCopxL4zRyaQ==", + "license": "MIT", + "engines": { + "node": ">=14.7.0" + }, + "optionalDependencies": { + "@types/linkify-it": "^3.0.1", + "@types/markdown-it": "12.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.3.0.tgz", + "integrity": "sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.3.0.tgz", + "integrity": "sha512-zlGHA23uuXmS8z3XxEGmbHpWDxXfPZ47QS06tGUq0HDcZjXjXHeLG+cboOy828QIV5FXsm9MjfkP5e4ZNbOkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.3.0.tgz", + "integrity": "sha512-CGKT9ccd3+oRK6STXGgfH+m0DbOKayX6QGlq38TfE1ZfUcPc5+ulTuzDbZUnMo+bubsEOIypm4Pl2iEyzZ1cNg==", + "license": "MIT" + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.3.0.tgz", + "integrity": "sha512-8lsxNAiBRUk72JvetSBXs4WRpYrQrVJXjlRRnOL6UCdBN9Nlsz0t7hWstRk36+JqHpGWOKYiuHLzGYqYAqoOnQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.3.0.tgz", + "integrity": "sha512-hAvqdPJv7A20Ucb6FQGE6jhjqy+vZ6pf+s2tFMNtMBG+fzcdc91uTw7aP/1Vo5plD0dAOHwdxfkyw0ugal4kcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.3.0.tgz", + "integrity": "sha512-BR1bIRWOMqkf8IoU576YDhij1Wd/Zf2kX/kCI0b2qzCKC8wcc2GQJaaRMCpzvCCrmliO4vtJ6RITp/AnoYUUmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.1.tgz", + "integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro-expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.41.7.tgz", + "integrity": "sha512-hUpogGc6DdAd+I7pPXsctyYPRBJDK7Q7d06s4cyP0Vz3OcbziP3FNzN0jZci1BpCvLn9675DvS7B9ctKKX64JQ==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.41.7" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" + } + }, + "node_modules/astro-relative-links": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/astro-relative-links/-/astro-relative-links-0.4.2.tgz", + "integrity": "sha512-YXwnmZz47yxiIIxWR10LwpGlqvGTERJ7JWX2lFRINTg/sFaPkqTx9xNM50W1+9FNMMxtTn7C8YNW5yDVX33Vzw==", + "license": "MIT", + "dependencies": { + "glob": "^10.3.10" + }, + "peerDependencies": { + "astro": ">=3.0.0" + } + }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", + "license": "MIT" + }, + "node_modules/astro/node_modules/@astrojs/markdown-remark": { + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/astro/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/astro/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/bcp-47": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz", + "integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.7.tgz", + "integrity": "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7", + "@expressive-code/plugin-frames": "^0.41.7", + "@expressive-code/plugin-shiki": "^0.41.7", + "@expressive-code/plugin-text-markers": "^0.41.7" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.2.tgz", + "integrity": "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/h3": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.2.tgz", + "integrity": "sha512-SfMzfdAi/zAoZ1KkFEyyeXBn7u/ShQrfd675ZEE9M3qj+PMFX05xubzRyF76CCSJu8au9jgVxDV1+okFvgZU4A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^6.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.0.tgz", + "integrity": "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz", + "integrity": "sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", + "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.0.tgz", + "integrity": "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz", + "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.3.0.tgz", + "integrity": "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.3.0", + "@pagefind/darwin-x64": "1.3.0", + "@pagefind/linux-arm64": "1.3.0", + "@pagefind/linux-x64": "1.3.0", + "@pagefind/windows-x64": "1.3.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.7.tgz", + "integrity": "sha512-25f8ZMSF1d9CMscX7Cft0TSQIqdwjce2gDOvQ+d/w0FovsMwrSt3ODP4P3Z7wO1jsIJ4eYyaDRnIR/27bd/EMQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.41.7" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", + "integrity": "sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.3.tgz", + "integrity": "sha512-9Ew1tR2WYw8RGE2XLy7GjkusvYXy8Rg6y8TYuBuQMfIEdGcWoJpY2Wr5DzsEiL/TKCw56+YKTCCUHglorEYK+A==", + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.31.0.tgz", + "integrity": "sha512-yCxltHW07Nkhv/1F6wWBr8kz+5BGMfP+RbRSYFnegVb0qV/UMT0G0ElBloPVerqn4M2ZV80Ir1FtCcYv1cT6vQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/sources/vonage_video_ios_app-theme-customization/package.json b/sources/vonage_video_ios_app-theme-customization/package.json new file mode 100644 index 00000000..a05692a9 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/package.json @@ -0,0 +1,22 @@ +{ + "name": "@example/starlight-basics", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "node download-toolbar.js && astro dev", + "start": "node download-toolbar.js && astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/markdoc": "^0.15.10", + "@astrojs/starlight": "^0.37.6", + "@astrojs/starlight-markdoc": "^0.5.1", + "adm-zip": "^0.5.16", + "astro": "^5.18.1", + "astro-relative-links": "^0.4.2", + "sharp": "^0.33.0" + } +} diff --git a/sources/vonage_video_ios_app-theme-customization/public/favicon.svg b/sources/vonage_video_ios_app-theme-customization/public/favicon.svg new file mode 100644 index 00000000..cba5ac14 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/setup-project.js b/sources/vonage_video_ios_app-theme-customization/setup-project.js new file mode 100644 index 00000000..d3f5a12f --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/setup-project.js @@ -0,0 +1 @@ + diff --git a/sources/vonage_video_ios_app-theme-customization/src/assets/houston.webp b/sources/vonage_video_ios_app-theme-customization/src/assets/houston.webp new file mode 100644 index 00000000..930c1649 Binary files /dev/null and b/sources/vonage_video_ios_app-theme-customization/src/assets/houston.webp differ diff --git a/sources/vonage_video_ios_app-theme-customization/src/content.config.ts b/sources/vonage_video_ios_app-theme-customization/src/content.config.ts new file mode 100644 index 00000000..95372921 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/sources/vonage_video_ios_app-theme-customization/src/content/docs/01_First_Step.md b/sources/vonage_video_ios_app-theme-customization/src/content/docs/01_First_Step.md new file mode 100644 index 00000000..d71ea4c2 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/src/content/docs/01_First_Step.md @@ -0,0 +1,17 @@ +--- +title: Introduction +description: An introduction to the onboarding tutorial +--- + +# Starter Tutorial + +This is a paragraph about what the tutorial contains. + +Here is some code: + +```js +function demo() { + // This is a comment + console.log('Hello World'); +} +``` diff --git a/sources/vonage_video_ios_app-theme-customization/src/content/docs/index.mdx b/sources/vonage_video_ios_app-theme-customization/src/content/docs/index.mdx new file mode 100644 index 00000000..bce9fdc3 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/src/content/docs/index.mdx @@ -0,0 +1,7 @@ +--- +title: Starter Tutorial +description: Get started building a tutorial. +template: splash +hero: + tagline: Starting a tutorial! +--- diff --git a/sources/vonage_video_ios_app-theme-customization/src/content/docs/reference.mdoc b/sources/vonage_video_ios_app-theme-customization/src/content/docs/reference.mdoc new file mode 100644 index 00000000..a9ec9429 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/src/content/docs/reference.mdoc @@ -0,0 +1,144 @@ +--- +title: Reference +description: Get started building your docs site with Starlight. + +hero: + tagline: Components that can be used in Tutorials +--- + +## Asides + +{% aside %} +Note aside. +{% /aside %} + +``` +{% aside %} +Note aside. +{% /aside %} +``` + +{% aside type="caution" %} +Caution aside. +{% /aside %} + +``` +{% aside type="caution" %} +Caution aside. +{% /aside %} +``` + +{% aside type="tip" %} +Tip aside. +{% /aside %} + +``` +{% aside type="tip" %} +Tip aside. +{% /aside %} +``` + +{% aside type="danger" %} +Danger aside. +{% /aside %} + +``` +{% aside type="danger" %} +Danger aside. +{% /aside %} +``` + +## Blockquotes + +> This is a blockquote. +> +> This is another line in the blockquote. + +## Code Blocks + +```js +function demo() { + // This is a comment + console.log('Hello World'); +} +``` + +## File Trees + +{% filetree %} + +- astro.config.mjs +- package.json +- src + - components + - Header.astro + - Title.astro + - pages/ + +{% /filetree %} + +``` +{% filetree %} + +- astro.config.mjs +- package.json +- src + - components + - Header.astro + - Title.astro + - pages/ + +{% /filetree %} +``` + +## Steps + +{% steps %} + +1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + +2. Wrap `` around your ordered list items. + +{% /steps %} + +``` + {% steps %} + + 1. Import the component into your MDX file: + + ```js + import { Steps } from '@astrojs/starlight/components'; + ``` + + 2. Wrap `` around your ordered list items. + + {% /steps %} +``` + +## Tabs + +{% tabs %} +{% tabitem label="Tab 1" %} +Tab 1 text +{% /tabitem %} + +{% tabitem label="Tab 2" %} +Tab 2 text +{% /tabitem %} +{% /tabs %} + +``` +{% tabs %} +{% tabitem label="Tab 1" %} +Tab 1 text +{% /tabitem %} + +{% tabitem label="Tab 2" %} +Tab 2 text +{% /tabitem %} +{% /tabs %} +``` diff --git a/sources/vonage_video_ios_app-theme-customization/src/env.d.ts b/sources/vonage_video_ios_app-theme-customization/src/env.d.ts new file mode 100644 index 00000000..acef35f1 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/sources/vonage_video_ios_app-theme-customization/tsconfig.json b/sources/vonage_video_ios_app-theme-customization/tsconfig.json new file mode 100644 index 00000000..bcbf8b50 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/sources/vonage_video_ios_app-theme-customization/tutorial-config.json b/sources/vonage_video_ios_app-theme-customization/tutorial-config.json new file mode 100644 index 00000000..1b5a8c41 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/tutorial-config.json @@ -0,0 +1,13 @@ +{ + "files": [], + "openFiles": [], + "panels": [], + "repository": "https://github.com/Vonage/vonage-video-react-app", + "starterFiles": [], + "capabilities": [], + "version": "1.0.0", + "filename": "vonage_video_ios_app-theme-customization", + "customInstallCmd": "yarn", + "customSetupCmd": "", + "customStartCmd": "" +} \ No newline at end of file diff --git a/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/README.md b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/README.md new file mode 100644 index 00000000..d5f36b4d --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/README.md @@ -0,0 +1,6 @@ +# Vonage toolbar application for authoring tutorials + +In the preview, there will be a toolbar in the bottom middle of the page. +Click the Vonage logo for the instructions. + +![Screenshot of toolbar app. A dark gray rectangular interface element with rounded corners, displaying a list of six steps in white text, each preceded by a right-pointing triangle. Below the rectangle is a smaller dark gray rounded rectangle containing five white icons: an Astro logo, cursor arrow, three horizontal lines with a magnification glass, a V, and a gear icon. ](./toolbar.png) diff --git a/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/app.ts b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/app.ts new file mode 100644 index 00000000..9861ba96 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/app.ts @@ -0,0 +1,470 @@ +import { defineToolbarApp } from 'astro/toolbar'; + +let tutorial: { + files: string[]; + openFiles: string[]; + panels: string[]; + repository: string; + starterFiles: string[]; + capabilities: string[]; + version: string; + filename: string; + customInstallCmd: string; + customSetupCmd: string; + customStartCmd: string; +} = { + files: [], + openFiles: [], + panels: [], + repository: '', + starterFiles: [], + capabilities: [], + version: '', + filename: '', + customInstallCmd: '', + customSetupCmd: '', + customStartCmd: '' +}; + +export default defineToolbarApp({ + init(canvas, app, server) { + const myWindow = document.createElement('astro-dev-toolbar-window'); + const myContent = document.createElement('div'); + myContent.style.overflowY = 'auto'; + myContent.innerHTML = ` +
+ Step 1: Select panels needed + Please select other panels used in the tutorial +
+
+ + +
+
+ + +
+
+
+
+ Step 2: Load an external repo (optional) + The repo will be loaded in the project folder. + +

Default install command is npm install, if you have custom install command, please enter it here: + +

Default setup command is node setup-project.js that will run the code in the setup-project.js file. If you have a custom setup command, please enter it here and make sure to create any files you need: + + +

Default start command is npm start, if you have custom start command, please enter it here: +
+
+ Step 3: Set Starter Files to be used + Starter Files are files that have the code that the user does not need to worry about. They have comments to let the user know where the code they see in the tutorial needs to go. If you are pulling from an external repo make sure to add 'project/' in front and it will overwrite the external repo file for the tutorial otherwise place the files in the root directory. +
+ please include filename AND filetype +
Starter File list: +
    +
    +
    + Step 4: Set Files to be opened + Please enter the names and file type of the files to be opened for the tutorial one at a time. If from external repo make sure to add 'project/' in front. +
    + please include filename AND filetype +
    Open File list: +
      +
      +
      + Step 5: Create steps + In the src -> content -> docs folder, please add the steps for the tutorial. If you are using any components from the Reference page, please rename the file type from '.md' to '.mdoc' Delete reference.mdoc when finished. +

      See Reference for components you can add.

      +
      +
      + Step 6: Set Files to be created + Please enter the names and file type of the files to be created for the tutorial one at a time. They will be opened in the editor. If the file should be in a folder, make sure to put it in front of the filename ex. 'public/'. +
      + please include filename AND filetype +
      File list: +
        +
        +
        + Step 7: Select capabilities needed + Please select any capabilities used in the tutorial +
        +
        + + +
        +
        +
        +
        + Step 8: Enter version + +
        +
        + Step 9: Enter filename for the zip file + This will be used also for the repo's folder name +
        +
        +
        + Step 10: Finish up + Click to start generating the tutorial: +

        + + Click to download +
        Then rename the zip file and upload the zip file to the GitHub repository uploads folder. +
        +
        + `; + // use appendChild directly on `window`, not `myWindow.shadowRoot` + myWindow.appendChild(myContent); + + canvas.append(myWindow); + + const astroToolbarWindow = canvas.querySelector('astro-dev-toolbar-window'); + + const filenameInput = astroToolbarWindow?.querySelector( + '#filename' + ) as HTMLInputElement; + filenameInput.value = tutorial.filename !== '' ? tutorial.filename : ''; + filenameInput?.addEventListener('change', (event) => { + tutorial.filename = filenameInput?.value; + saveConfig(); + }); + + const versionInput = astroToolbarWindow?.querySelector( + '#version' + ) as HTMLInputElement; + versionInput.value = tutorial.version !== '' ? tutorial.version : ''; + versionInput?.addEventListener('change', (event) => { + tutorial.version = versionInput?.value; + saveConfig(); + }); + + const repositoryInput = astroToolbarWindow?.querySelector( + '#repository' + ) as HTMLInputElement; + repositoryInput.value = + tutorial.repository !== '' ? tutorial.repository : ''; + repositoryInput?.addEventListener('change', (event) => { + tutorial.repository = repositoryInput?.value; + saveConfig(); + }); + + + const customInstallCmdInput = astroToolbarWindow?.querySelector( + '#custom-install-command' + ) as HTMLInputElement; + customInstallCmdInput.value = + tutorial.customInstallCmd !== '' ? tutorial.customInstallCmd : ''; + customInstallCmdInput?.addEventListener('change', (event) => { + tutorial.customInstallCmd = customInstallCmdInput?.value; + saveConfig(); + }); + + const customSetupCmdInput = astroToolbarWindow?.querySelector( + '#custom-setup-command' + ) as HTMLInputElement; + customSetupCmdInput.value = + tutorial.customSetupCmd !== '' ? tutorial.customSetupCmd : ''; + customSetupCmdInput?.addEventListener('change', (event) => { + tutorial.customSetupCmd = customSetupCmdInput?.value; + saveConfig(); + }); + + const customStartCmdInput = astroToolbarWindow?.querySelector( + '#custom-start-command' + ) as HTMLInputElement; + customStartCmdInput.value = + tutorial.customStartCmd !== '' ? tutorial.customStartCmd : ''; + customStartCmdInput?.addEventListener('change', (event) => { + tutorial.customStartCmd = customStartCmdInput?.value; + saveConfig(); + }); + + // Always read tutorial-config.json from disk on toolbar open + server.send('vonage-app:config-check', {}); + + server.on('config-checked', (data: any) => { + if (data.found) { + tutorial = data.tutorial; + saveConfig(); + updateUI(); + } + }); + + function updateUI() { + refreshFilesList(); + refreshFilesOpenList(); + refreshStarterFilesList(); + if (tutorial.panels.length !== 0) { + tutorial.panels.forEach((panel) => { + ( + astroToolbarWindow?.querySelector(`#${panel}`) as HTMLInputElement + ).checked = true; + }); + } + + if (tutorial.capabilities.length !== 0) { + tutorial.capabilities.forEach((capability) => { + ( + astroToolbarWindow?.querySelector( + `#${capability}` + ) as HTMLInputElement + ).checked = true; + }); + } + versionInput.value = tutorial.version; + repositoryInput.value = tutorial.repository; + filenameInput.value = tutorial.filename; + customInstallCmdInput.value = tutorial.customInstallCmd; + customSetupCmdInput.value = tutorial.customSetupCmd; + } + + + const completeSpan = astroToolbarWindow?.querySelector( + '#complete' + ) as HTMLSpanElement; + + completeSpan.style.display = 'none'; + + const panelsForm = astroToolbarWindow?.querySelector('#panels'); + + panelsForm?.addEventListener('change', (event) => { + tutorial.panels = []; + const panelsChecked = astroToolbarWindow?.querySelectorAll( + 'input[type="checkbox"][name="panels"]:checked' + ); + panelsChecked?.forEach((panel) => { + tutorial.panels.push(panel.id); + }); + saveConfig(); + }); + + const capabilitiesForm = astroToolbarWindow?.querySelector('#capabilities'); + + capabilitiesForm?.addEventListener('change', (event) => { + console.log('capabilities change event'); + tutorial.capabilities = []; + const capabilitiesChecked = astroToolbarWindow?.querySelectorAll( + 'input[type="checkbox"][name="capabilities"]:checked' + ); + capabilitiesChecked?.forEach((capability) => { + tutorial.capabilities.push(capability.id); + }); + saveConfig(); + }); + + function saveConfig() { + server.send('vonage-app:save-config', { tutorial }); + } + + function refreshFilesList() { + const fileUl = astroToolbarWindow?.querySelector( + '#file-list' + ) as HTMLButtonElement; + fileUl.innerHTML = ''; + tutorial.files = Array.from(new Set(tutorial.files)); + tutorial.files.forEach((file) => { + const fileLi = document.createElement('li'); + fileLi.id = file; + fileLi.innerText = file + ' '; + fileLi.classList.add('file'); + const fileButton = document.createElement('button'); + fileButton.dataset.id = file; + fileButton.innerText = 'delete'; + fileButton.addEventListener('click', (event) => { + const id = (event.currentTarget as HTMLElement).dataset.id; + if (!id) return; + const idx = tutorial.files.indexOf(id); + if (idx > -1) { + tutorial.files.splice(idx, 1); + refreshFilesList(); + } + // tutorial.files.splice( + // tutorial.files.indexOf((event.target as HTMLElement).dataset.id), + // 1 + // ); + // refreshFilesList(); + }); + fileLi.appendChild(fileButton); + fileUl.appendChild(fileLi); + }); + saveConfig(); + } + + const fileInputError = astroToolbarWindow?.querySelector( + '#file-input-error' + ) as HTMLElement; + + fileInputError.style.display = 'none'; + + astroToolbarWindow + ?.querySelector('#add-file') + ?.addEventListener('click', (event) => { + fileInputError.style.display = 'none'; + const fileInput = astroToolbarWindow?.querySelector( + '#file-input' + ) as HTMLInputElement; + // make sure has extension + if (fileInput.value.includes('.')) { + tutorial.files = [...tutorial.files, fileInput.value]; + fileInput.value = ''; + refreshFilesList(); + } else { + fileInputError.style.display = 'block'; + } + }); + + function refreshFilesOpenList() { + const fileUl = astroToolbarWindow?.querySelector( + '#file-open-list' + ) as HTMLButtonElement; + fileUl.innerHTML = ''; + tutorial.openFiles = Array.from(new Set(tutorial.openFiles)); + tutorial.openFiles.forEach((file) => { + const fileLi = document.createElement('li'); + fileLi.id = file; + fileLi.innerText = file + ' '; + fileLi.classList.add('file'); + const fileButton = document.createElement('button'); + fileButton.dataset.id = file; + fileButton.innerText = 'delete'; + fileButton.addEventListener('click', (event) => { + const id = (event.currentTarget as HTMLElement).dataset.id; + if (!id) return; + const idx = tutorial.openFiles.indexOf(id); + if (idx > -1) { + tutorial.openFiles.splice(idx, 1); + refreshFilesOpenList(); + } + // tutorial.files.splice( + // tutorial.files.indexOf((event.target as HTMLElement).dataset.id), + // 1 + // ); + // refreshFilesList(); + }); + fileLi.appendChild(fileButton); + fileUl.appendChild(fileLi); + }); + saveConfig(); + } + + const fileOpenInputError = astroToolbarWindow?.querySelector( + '#file-open-input-error' + ) as HTMLElement; + + fileOpenInputError.style.display = 'none'; + + astroToolbarWindow + ?.querySelector('#add-file-open') + ?.addEventListener('click', (event) => { + fileOpenInputError.style.display = 'none'; + const fileOpenInput = astroToolbarWindow?.querySelector( + '#file-open-input' + ) as HTMLInputElement; + // make sure has extension + if (fileOpenInput.value.includes('.')) { + tutorial.openFiles = [...tutorial.openFiles, fileOpenInput.value]; + fileOpenInput.value = ''; + refreshFilesOpenList(); + } else { + fileOpenInputError.style.display = 'block'; + } + }); + + + // Starter files + + function refreshStarterFilesList() { + const fileUl = astroToolbarWindow?.querySelector( + '#starter-file-list' + ) as HTMLButtonElement; + fileUl.innerHTML = ''; + tutorial.starterFiles = Array.from(new Set(tutorial.starterFiles)); + tutorial.starterFiles.forEach((file) => { + const fileLi = document.createElement('li'); + fileLi.id = file; + fileLi.innerText = file + ' '; + fileLi.classList.add('file'); + const fileButton = document.createElement('button'); + fileButton.dataset.id = file; + fileButton.innerText = 'delete'; + fileButton.addEventListener('click', (event) => { + const id = (event.currentTarget as HTMLElement).dataset.id; + if (!id) return; + const idx = tutorial.starterFiles.indexOf(id); + if (idx > -1) { + tutorial.starterFiles.splice(idx, 1); + refreshStarterFilesList(); + } + // tutorial.files.splice( + // tutorial.files.indexOf((event.target as HTMLElement).dataset.id), + // 1 + // ); + // refreshFilesList(); + }); + fileLi.appendChild(fileButton); + fileUl.appendChild(fileLi); + }); + saveConfig(); + } + + const starterFileInputError = astroToolbarWindow?.querySelector( + '#starter-file-input-error' + ) as HTMLElement; + + starterFileInputError.style.display = 'none'; + + astroToolbarWindow + ?.querySelector('#add-starter-file') + ?.addEventListener('click', (event) => { + starterFileInputError.style.display = 'none'; + const starterFileInput = astroToolbarWindow?.querySelector( + '#starter-file-input' + ) as HTMLInputElement; + // make sure has extension + if (starterFileInput.value.includes('.')) { + tutorial.starterFiles = [...tutorial.starterFiles, starterFileInput.value]; + starterFileInput.value = ''; + refreshStarterFilesList(); + } else { + starterFileInputError.style.display = 'block'; + } + }); + /////// + + const generateButton = astroToolbarWindow?.querySelector( + '#generate' + ) as HTMLButtonElement; + + const statusEl = astroToolbarWindow?.querySelector( + '#status' + ) as HTMLParagraphElement; + + generateButton?.addEventListener('click', (event) => { + generateButton.disabled = true; + statusEl.innerText = ''; + completeSpan.style.display = 'none'; + server.send('vonage-app:generate', { tutorial }); + }); + + server.on('server-status', (data: any) => { + statusEl.innerText = data.status; + if (data.status === 'Complete!') { + astroToolbarWindow?.querySelector('#complete') as HTMLParagraphElement; + ( + astroToolbarWindow?.querySelector( + '#download-link' + ) as HTMLAnchorElement + ).style = `color: white; background-color: black`; + ( + astroToolbarWindow?.querySelector( + '#download-link' + ) as HTMLAnchorElement + ).href = `${window.location.origin}/${tutorial.filename}.zip`; + generateButton.disabled = false; + completeSpan.style.display = 'block'; + } + }); + }, +}); diff --git a/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/integration.ts b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/integration.ts new file mode 100644 index 00000000..8d35ada6 --- /dev/null +++ b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/integration.ts @@ -0,0 +1,87 @@ +import { fileURLToPath } from 'node:url'; +import type { AstroIntegration } from 'astro'; +import { exec } from 'child_process'; +import util from 'util'; +import AdmZip from 'adm-zip'; +import fs from 'fs/promises'; +import path from 'path'; +export default { + name: 'vonage-onboarding-integration', + hooks: { + 'astro:config:setup': ({ addDevToolbarApp }) => { + addDevToolbarApp({ + id: 'vonage-toolbar-app', + name: 'Vonage Toolbar App', + icon: 'Vonage', + entrypoint: fileURLToPath(new URL('./app.ts', import.meta.url)), + }); + }, + 'astro:server:setup': ({ toolbar }) => { + + toolbar.on('vonage-app:config-check', async (data:any) => { + try { + const filePath = 'tutorial-config.json'; + const fileData = await fs.readFile(filePath, 'utf8'); + const config = JSON.parse(fileData); + toolbar.send('config-checked', { + found: true, + tutorial: config, + }); + + } catch (err) { + if (err.code === 'ENOENT') { + console.error('Config file not found.'); + toolbar.send('config-checked', { + found: false, + tutorial: {}, + }); + } else { + console.error('Error reading config file:', err); + toolbar.send('config-checked', { + found: false, + tutorial: {}, + }); + } + } + }); + + toolbar.on('vonage-app:save-config', async (data: any) => { + try { + const configData = JSON.stringify(data.tutorial, null, 2); + await fs.writeFile('tutorial-config.json', configData); + } catch (err) { + console.error('Error saving config:', err); + } + }); + + toolbar.on('vonage-app:generate', async (data: any) => { + try { + // create tutorial-config.json file + toolbar.send('server-status', { + status: 'Creating configuration file (tutorial-config.json)', + }); + const configData = JSON.stringify(data.tutorial, null, 2); + await fs.writeFile('tutorial-config.json', configData); + + // Zip up the whole project folder without the node_module folder and name it the slug generated from the title + toolbar.send('server-status', { + status: 'Zipping up project... could take a minute', + }); + const zip = new AdmZip(); + let exclude = ['node_modules', 'dist']; + const sourceDir = './'; + const zipFile = data.tutorial.filename === '' ? './public/product_name-language-topic.zip' : `./public/${data.tutorial.filename}.zip`; + await zip.addLocalFolderPromise(sourceDir, { + filter: (filePath) => !exclude.some((ex) => filePath.includes(ex)), + }); + await zip.writeZipPromise(zipFile); + console.log(`Zip file ${zipFile} created successfully!`); + // display link to download project zip file in toolbar. + toolbar.send('server-status', { status: 'Complete!' }); + } catch (error) { + console.error('Error:', error); + } + }); + }, + }, +} satisfies AstroIntegration; diff --git a/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/toolbar.png b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/toolbar.png new file mode 100644 index 00000000..f5bc53e5 Binary files /dev/null and b/sources/vonage_video_ios_app-theme-customization/vonage-toolbar/toolbar.png differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json b/tutorials/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json new file mode 100644 index 00000000..15dc3fb5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/.astro/collections/docs.schema.json @@ -0,0 +1,650 @@ +{ + "$ref": "#/definitions/docs", + "definitions": { + "docs": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "editUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "boolean" + } + ], + "default": true + }, + "head": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "title", + "base", + "link", + "style", + "meta", + "script", + "noscript", + "template" + ] + }, + "attrs": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "not": {} + } + ] + } + }, + "content": { + "type": "string" + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + "default": [] + }, + "tableOfContents": { + "anyOf": [ + { + "type": "object", + "properties": { + "minHeadingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 2 + }, + "maxHeadingLevel": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 3 + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ], + "default": { + "minHeadingLevel": 2, + "maxHeadingLevel": 3 + } + }, + "template": { + "type": "string", + "enum": [ + "doc", + "splash" + ], + "default": "doc" + }, + "hero": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "tagline": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "object", + "properties": { + "alt": { + "type": "string", + "default": "" + }, + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "alt": { + "type": "string", + "default": "" + }, + "dark": { + "type": "string" + }, + "light": { + "type": "string" + } + }, + "required": [ + "dark", + "light" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "html": { + "type": "string" + } + }, + "required": [ + "html" + ], + "additionalProperties": false + } + ] + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "link": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "primary", + "secondary", + "minimal" + ], + "default": "primary" + }, + "icon": { + "anyOf": [ + { + "type": "string", + "enum": [ + "up-caret", + "down-caret", + "right-caret", + "left-caret", + "up-arrow", + "down-arrow", + "right-arrow", + "left-arrow", + "bars", + "translate", + "pencil", + "pen", + "document", + "add-document", + "setting", + "external", + "download", + "cloud-download", + "moon", + "sun", + "laptop", + "open-book", + "information", + "magnifier", + "forward-slash", + "close", + "error", + "warning", + "approve-check-circle", + "approve-check", + "rocket", + "star", + "puzzle", + "list-format", + "random", + "comment", + "comment-alt", + "heart", + "github", + "gitlab", + "bitbucket", + "codePen", + "farcaster", + "discord", + "gitter", + "twitter", + "x.com", + "mastodon", + "codeberg", + "youtube", + "threads", + "linkedin", + "twitch", + "azureDevOps", + "microsoftTeams", + "instagram", + "stackOverflow", + "telegram", + "rss", + "facebook", + "email", + "phone", + "reddit", + "patreon", + "signal", + "slack", + "matrix", + "hackerOne", + "openCollective", + "blueSky", + "discourse", + "zulip", + "pinterest", + "tiktok", + "astro", + "alpine", + "pnpm", + "biome", + "bun", + "mdx", + "apple", + "linux", + "homebrew", + "nix", + "starlight", + "pkl", + "node", + "cloudflare", + "vercel", + "netlify", + "deno", + "jsr", + "nostr", + "backstage", + "confluence", + "jira", + "storybook", + "vscode", + "jetbrains", + "zed", + "vim", + "figma", + "sketch", + "npm", + "sourcehut", + "substack", + "chrome", + "edge", + "firefox", + "safari", + "seti:folder", + "seti:bsl", + "seti:mdo", + "seti:salesforce", + "seti:asm", + "seti:bicep", + "seti:bazel", + "seti:c", + "seti:c-sharp", + "seti:html", + "seti:cpp", + "seti:clojure", + "seti:coldfusion", + "seti:config", + "seti:crystal", + "seti:crystal_embedded", + "seti:json", + "seti:css", + "seti:csv", + "seti:xls", + "seti:cu", + "seti:cake", + "seti:cake_php", + "seti:d", + "seti:word", + "seti:elixir", + "seti:elixir_script", + "seti:hex", + "seti:elm", + "seti:favicon", + "seti:f-sharp", + "seti:git", + "seti:go", + "seti:godot", + "seti:gradle", + "seti:grails", + "seti:graphql", + "seti:hacklang", + "seti:haml", + "seti:mustache", + "seti:haskell", + "seti:haxe", + "seti:jade", + "seti:java", + "seti:javascript", + "seti:jinja", + "seti:julia", + "seti:karma", + "seti:kotlin", + "seti:dart", + "seti:liquid", + "seti:livescript", + "seti:lua", + "seti:markdown", + "seti:argdown", + "seti:info", + "seti:clock", + "seti:maven", + "seti:nim", + "seti:github", + "seti:notebook", + "seti:nunjucks", + "seti:npm", + "seti:ocaml", + "seti:odata", + "seti:perl", + "seti:php", + "seti:pipeline", + "seti:pddl", + "seti:plan", + "seti:happenings", + "seti:powershell", + "seti:prisma", + "seti:pug", + "seti:puppet", + "seti:purescript", + "seti:python", + "seti:react", + "seti:rescript", + "seti:R", + "seti:ruby", + "seti:rust", + "seti:sass", + "seti:spring", + "seti:slim", + "seti:smarty", + "seti:sbt", + "seti:scala", + "seti:ethereum", + "seti:stylus", + "seti:svelte", + "seti:swift", + "seti:db", + "seti:terraform", + "seti:tex", + "seti:default", + "seti:twig", + "seti:typescript", + "seti:tsconfig", + "seti:vala", + "seti:vite", + "seti:vue", + "seti:wasm", + "seti:wat", + "seti:xml", + "seti:yml", + "seti:prolog", + "seti:zig", + "seti:zip", + "seti:wgt", + "seti:illustrator", + "seti:photoshop", + "seti:pdf", + "seti:font", + "seti:image", + "seti:svg", + "seti:sublime", + "seti:code-search", + "seti:shell", + "seti:video", + "seti:audio", + "seti:windows", + "seti:jenkins", + "seti:babel", + "seti:bower", + "seti:docker", + "seti:code-climate", + "seti:eslint", + "seti:firebase", + "seti:firefox", + "seti:gitlab", + "seti:grunt", + "seti:gulp", + "seti:ionic", + "seti:platformio", + "seti:rollup", + "seti:stylelint", + "seti:yarn", + "seti:webpack", + "seti:lock", + "seti:license", + "seti:makefile", + "seti:heroku", + "seti:todo", + "seti:ignored" + ] + }, + { + "type": "string", + "pattern": "^\\ import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Findex.mdx&astroContentModuleFlag=true")], +["src/content/docs/reference.mdoc", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=src%2Fcontent%2Fdocs%2Freference.mdoc&astroContentModuleFlag=true")]]); + \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/.astro/content.d.ts b/tutorials/vonage_video_ios_app-theme-customization/.astro/content.d.ts new file mode 100644 index 00000000..ba5cbfb8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/.astro/content.d.ts @@ -0,0 +1,227 @@ +declare module 'astro:content' { + interface Render { + '.mdoc': Promise<{ + Content(props: Record): import('astro').MarkdownInstance<{}>['Content']; + headings: import('astro').MarkdownHeading[]; + }>; + } +} + +declare module 'astro:content' { + interface Render { + '.mdx': Promise<{ + Content: import('astro').MDXContent; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + components: import('astro').MDXInstance<{}>['components']; + }>; + } +} + +declare module 'astro:content' { + export interface RenderResult { + Content: import('astro/runtime/server/index.js').AstroComponentFactory; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + } + interface Render { + '.md': Promise; + } + + export interface RenderedContent { + html: string; + metadata?: { + imagePaths: Array; + [key: string]: unknown; + }; + } +} + +declare module 'astro:content' { + type Flatten = T extends { [K: string]: infer U } ? U : never; + + export type CollectionKey = keyof AnyEntryMap; + export type CollectionEntry = Flatten; + + export type ContentCollectionKey = keyof ContentEntryMap; + export type DataCollectionKey = keyof DataEntryMap; + + type AllValuesOf = T extends any ? T[keyof T] : never; + type ValidContentEntrySlug = AllValuesOf< + ContentEntryMap[C] + >['slug']; + + export type ReferenceDataEntry< + C extends CollectionKey, + E extends keyof DataEntryMap[C] = string, + > = { + collection: C; + id: E; + }; + export type ReferenceContentEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}) = string, + > = { + collection: C; + slug: E; + }; + export type ReferenceLiveEntry = { + collection: C; + id: string; + }; + + /** @deprecated Use `getEntry` instead. */ + export function getEntryBySlug< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + // Note that this has to accept a regular string too, for SSR + entrySlug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + + /** @deprecated Use `getEntry` instead. */ + export function getDataEntryById( + collection: C, + entryId: E, + ): Promise>; + + export function getCollection>( + collection: C, + filter?: (entry: CollectionEntry) => entry is E, + ): Promise; + export function getCollection( + collection: C, + filter?: (entry: CollectionEntry) => unknown, + ): Promise[]>; + + export function getLiveCollection( + collection: C, + filter?: LiveLoaderCollectionFilterType, + ): Promise< + import('astro').LiveDataCollectionResult, LiveLoaderErrorType> + >; + + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + entry: ReferenceContentEntry, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + entry: ReferenceDataEntry, + ): E extends keyof DataEntryMap[C] + ? Promise + : Promise | undefined>; + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + slug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + collection: C, + id: E, + ): E extends keyof DataEntryMap[C] + ? string extends keyof DataEntryMap[C] + ? Promise | undefined + : Promise + : Promise | undefined>; + export function getLiveEntry( + collection: C, + filter: string | LiveLoaderEntryFilterType, + ): Promise, LiveLoaderErrorType>>; + + /** Resolve an array of entry references from the same collection */ + export function getEntries( + entries: ReferenceContentEntry>[], + ): Promise[]>; + export function getEntries( + entries: ReferenceDataEntry[], + ): Promise[]>; + + export function render( + entry: AnyEntryMap[C][string], + ): Promise; + + export function reference( + collection: C, + ): import('astro/zod').ZodEffects< + import('astro/zod').ZodString, + C extends keyof ContentEntryMap + ? ReferenceContentEntry> + : ReferenceDataEntry + >; + // Allow generic `string` to avoid excessive type errors in the config + // if `dev` is not running to update as you edit. + // Invalid collection names will be caught at build time. + export function reference( + collection: C, + ): import('astro/zod').ZodEffects; + + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; + type InferEntrySchema = import('astro/zod').infer< + ReturnTypeOrOriginal['schema']> + >; + + type ContentEntryMap = { + + }; + + type DataEntryMap = { + "docs": Record; + rendered?: RenderedContent; + filePath?: string; +}>; + + }; + + type AnyEntryMap = ContentEntryMap & DataEntryMap; + + type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< + infer TData, + infer TEntryFilter, + infer TCollectionFilter, + infer TError + > + ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; + type ExtractDataType = ExtractLoaderTypes['data']; + type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; + type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; + type ExtractErrorType = ExtractLoaderTypes['error']; + + type LiveLoaderDataType = + LiveContentConfig['collections'][C]['schema'] extends undefined + ? ExtractDataType + : import('astro/zod').infer< + Exclude + >; + type LiveLoaderEntryFilterType = + ExtractEntryFilterType; + type LiveLoaderCollectionFilterType = + ExtractCollectionFilterType; + type LiveLoaderErrorType = ExtractErrorType< + LiveContentConfig['collections'][C]['loader'] + >; + + export type ContentConfig = typeof import("../src/content.config.js"); + export type LiveContentConfig = never; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/.astro/data-store.json b/tutorials/vonage_video_ios_app-theme-customization/.astro/data-store.json new file mode 100644 index 00000000..27b0ccc2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/.astro/data-store.json @@ -0,0 +1 @@ +[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.18.1","content-config-digest","a30a942a2ceda2e1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"where\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":false,\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[null,null,null],\"rehypePlugins\":[[null,{\"experimentalHeadingIdCompat\":false}],null,[null,{\"themes\":[{\"name\":\"Night Owl No Italics\",\"type\":\"dark\",\"colors\":{\"focusBorder\":\"#122d42\",\"foreground\":\"#d6deeb\",\"disabledForeground\":\"#cccccc80\",\"descriptionForeground\":\"#d6deebb3\",\"errorForeground\":\"#ef5350\",\"icon.foreground\":\"#c5c5c5\",\"contrastActiveBorder\":null,\"contrastBorder\":\"#122d42\",\"textBlockQuote.background\":\"#7f7f7f1a\",\"textBlockQuote.border\":\"#007acc80\",\"textCodeBlock.background\":\"#4f4f4f\",\"textLink.activeForeground\":\"#3794ff\",\"textLink.foreground\":\"#3794ff\",\"textPreformat.foreground\":\"#d7ba7d\",\"textSeparator.foreground\":\"#ffffff2e\",\"editor.background\":\"#23262f\",\"editor.foreground\":\"#d6deeb\",\"editorLineNumber.foreground\":\"#4b6479\",\"editorLineNumber.activeForeground\":\"#c5e4fd\",\"editorActiveLineNumber.foreground\":\"#c6c6c6\",\"editor.selectionBackground\":\"#1d3b53\",\"editor.inactiveSelectionBackground\":\"#7e57c25a\",\"editor.selectionHighlightBackground\":\"#5f7e9779\",\"editorError.foreground\":\"#ef5350\",\"editorWarning.foreground\":\"#b39554\",\"editorInfo.foreground\":\"#3794ff\",\"editorHint.foreground\":\"#eeeeeeb2\",\"problemsErrorIcon.foreground\":\"#ef5350\",\"problemsWarningIcon.foreground\":\"#b39554\",\"problemsInfoIcon.foreground\":\"#3794ff\",\"editor.findMatchBackground\":\"#5f7e9779\",\"editor.findMatchHighlightBackground\":\"#1085bb5d\",\"editor.findRangeHighlightBackground\":\"#3a3d4166\",\"editorLink.activeForeground\":\"#4e94ce\",\"editorLightBulb.foreground\":\"#ffcc00\",\"editorLightBulbAutoFix.foreground\":\"#75beff\",\"diffEditor.insertedTextBackground\":\"#99b76d23\",\"diffEditor.insertedTextBorder\":\"#c5e47833\",\"diffEditor.removedTextBackground\":\"#ef535033\",\"diffEditor.removedTextBorder\":\"#ef53504d\",\"diffEditor.insertedLineBackground\":\"#9bb95533\",\"diffEditor.removedLineBackground\":\"#ff000033\",\"editorStickyScroll.background\":\"#011627\",\"editorStickyScrollHover.background\":\"#2a2d2e\",\"editorInlayHint.background\":\"#5f7e97cc\",\"editorInlayHint.foreground\":\"#ffffff\",\"editorInlayHint.typeBackground\":\"#5f7e97cc\",\"editorInlayHint.typeForeground\":\"#ffffff\",\"editorInlayHint.parameterBackground\":\"#5f7e97cc\",\"editorInlayHint.parameterForeground\":\"#ffffff\",\"editorPane.background\":\"#011627\",\"editorGroup.emptyBackground\":\"#011627\",\"editorGroup.focusedEmptyBorder\":null,\"editorGroupHeader.tabsBackground\":\"var(--sl-color-black)\",\"editorGroupHeader.tabsBorder\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"editorGroupHeader.noTabsBackground\":\"#011627\",\"editorGroupHeader.border\":null,\"editorGroup.border\":\"#011627\",\"editorGroup.dropBackground\":\"#7e57c273\",\"editorGroup.dropIntoPromptForeground\":\"#d6deeb\",\"editorGroup.dropIntoPromptBackground\":\"#021320\",\"editorGroup.dropIntoPromptBorder\":null,\"sideBySideEditor.horizontalBorder\":\"#011627\",\"sideBySideEditor.verticalBorder\":\"#011627\",\"scrollbar.shadow\":\"#010b14\",\"scrollbarSlider.background\":\"#ffffff17\",\"scrollbarSlider.hoverBackground\":\"#ffffff40\",\"scrollbarSlider.activeBackground\":\"#084d8180\",\"panel.background\":\"#011627\",\"panel.border\":\"#5f7e97\",\"panelTitle.activeBorder\":\"#5f7e97\",\"panelTitle.activeForeground\":\"#ffffffcc\",\"panelTitle.inactiveForeground\":\"#d6deeb80\",\"panelSectionHeader.background\":\"#80808051\",\"terminal.background\":\"#011627\",\"widget.shadow\":\"#011627\",\"editorWidget.background\":\"#021320\",\"editorWidget.foreground\":\"#d6deeb\",\"editorWidget.border\":\"#5f7e97\",\"quickInput.background\":\"#021320\",\"quickInput.foreground\":\"#d6deeb\",\"quickInputTitle.background\":\"#ffffff1a\",\"pickerGroup.foreground\":\"#d1aaff\",\"pickerGroup.border\":\"#011627\",\"editor.hoverHighlightBackground\":\"#7e57c25a\",\"editorHoverWidget.background\":\"#011627\",\"editorHoverWidget.foreground\":\"#d6deeb\",\"editorHoverWidget.border\":\"#5f7e97\",\"editorHoverWidget.statusBarBackground\":\"#011a2f\",\"titleBar.activeBackground\":\"var(--sl-color-black)\",\"titleBar.activeForeground\":\"var(--sl-color-text)\",\"titleBar.inactiveBackground\":\"#010e1a\",\"titleBar.inactiveForeground\":\"#eeefff99\",\"titleBar.border\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"toolbar.hoverBackground\":\"#5a5d5e50\",\"toolbar.activeBackground\":\"#63666750\",\"tab.activeBackground\":\"#0b2942\",\"tab.unfocusedActiveBackground\":\"#0b2942\",\"tab.inactiveBackground\":\"#01111d\",\"tab.unfocusedInactiveBackground\":\"#01111d\",\"tab.activeForeground\":\"var(--sl-color-text)\",\"tab.inactiveForeground\":\"#5f7e97\",\"tab.unfocusedActiveForeground\":\"#5f7e97\",\"tab.unfocusedInactiveForeground\":\"#5f7e97\",\"tab.hoverBackground\":null,\"tab.unfocusedHoverBackground\":null,\"tab.hoverForeground\":null,\"tab.unfocusedHoverForeground\":null,\"tab.border\":\"#272b3b\",\"tab.lastPinnedBorder\":\"#585858\",\"tab.activeBorder\":\"transparent\",\"tab.unfocusedActiveBorder\":\"#262a39\",\"tab.activeBorderTop\":\"var(--sl-color-accent-high)\",\"tab.unfocusedActiveBorderTop\":null,\"tab.hoverBorder\":null,\"tab.unfocusedHoverBorder\":null,\"tab.activeModifiedBorder\":\"#3399cc\",\"tab.inactiveModifiedBorder\":\"#3399cc80\",\"tab.unfocusedActiveModifiedBorder\":\"#3399cc80\",\"tab.unfocusedInactiveModifiedBorder\":\"#3399cc40\",\"badge.background\":\"#5f7e97\",\"badge.foreground\":\"#ffffff\",\"button.background\":\"#7e57c2cc\",\"button.foreground\":\"#ffffffcc\",\"button.border\":\"#122d42\",\"button.separator\":\"#ffffff52\",\"button.hoverBackground\":\"#7e57c2\",\"button.secondaryBackground\":\"#3a3d41\",\"button.secondaryForeground\":\"#ffffff\",\"button.secondaryHoverBackground\":\"#46494e\",\"dropdown.background\":\"#011627\",\"dropdown.foreground\":\"#ffffffcc\",\"dropdown.border\":\"#5f7e97\",\"list.activeSelectionBackground\":\"#234d708c\",\"list.activeSelectionForeground\":\"#ffffff\",\"tree.indentGuidesStroke\":\"#585858\",\"input.background\":\"#0b253a\",\"input.foreground\":\"#ffffffcc\",\"input.placeholderForeground\":\"#5f7e97\",\"inputOption.activeBorder\":\"#ffffffcc\",\"inputOption.hoverBackground\":\"#5a5d5e80\",\"inputOption.activeBackground\":\"#122d4266\",\"inputOption.activeForeground\":\"#ffffff\",\"inputValidation.infoBackground\":\"#00589ef2\",\"inputValidation.infoBorder\":\"#64b5f6\",\"inputValidation.warningBackground\":\"#675700f2\",\"inputValidation.warningBorder\":\"#ffca28\",\"inputValidation.errorBackground\":\"#ab0300f2\",\"inputValidation.errorBorder\":\"#ef5350\",\"keybindingLabel.background\":\"#8080802b\",\"keybindingLabel.foreground\":\"#cccccc\",\"keybindingLabel.border\":\"#33333399\",\"keybindingLabel.bottomBorder\":\"#44444499\",\"menu.foreground\":\"#ffffffcc\",\"menu.background\":\"#011627\",\"menu.selectionForeground\":\"#ffffff\",\"menu.selectionBackground\":\"#234d708c\",\"menu.separatorBackground\":\"#606060\",\"editor.snippetTabstopHighlightBackground\":\"#7c7c74c\",\"editor.snippetFinalTabstopHighlightBorder\":\"#525252\",\"terminal.ansiBlack\":\"#011627\",\"terminal.ansiRed\":\"#ef5350\",\"terminal.ansiGreen\":\"#22da6e\",\"terminal.ansiYellow\":\"#c5e478\",\"terminal.ansiBlue\":\"#82aaff\",\"terminal.ansiMagenta\":\"#c792ea\",\"terminal.ansiCyan\":\"#21c7a8\",\"terminal.ansiWhite\":\"#ffffff\",\"terminal.ansiBrightBlack\":\"#575656\",\"terminal.ansiBrightRed\":\"#ef5350\",\"terminal.ansiBrightGreen\":\"#22da6e\",\"terminal.ansiBrightYellow\":\"#ffeb95\",\"terminal.ansiBrightBlue\":\"#82aaff\",\"terminal.ansiBrightMagenta\":\"#c792ea\",\"terminal.ansiBrightCyan\":\"#7fdbca\",\"terminal.ansiBrightWhite\":\"#ffffff\",\"selection.background\":\"#4373c2\",\"input.border\":\"#5f7e97\",\"punctuation.definition.generic.begin.html\":\"#ef5350f2\",\"progress.background\":\"#7e57c2\",\"breadcrumb.foreground\":\"#a599e9\",\"breadcrumb.focusForeground\":\"#ffffff\",\"breadcrumb.activeSelectionForeground\":\"#ffffff\",\"breadcrumbPicker.background\":\"#001122\",\"list.invalidItemForeground\":\"#975f94\",\"list.dropBackground\":\"#011627\",\"list.focusBackground\":\"#010d18\",\"list.focusForeground\":\"#ffffff\",\"list.highlightForeground\":\"#ffffff\",\"list.hoverBackground\":\"#011627\",\"list.hoverForeground\":\"#ffffff\",\"list.inactiveSelectionBackground\":\"#0e293f\",\"list.inactiveSelectionForeground\":\"#5f7e97\",\"activityBar.background\":\"#011627\",\"activityBar.dropBackground\":\"#5f7e97\",\"activityBar.foreground\":\"#5f7e97\",\"activityBar.border\":\"#011627\",\"activityBarBadge.background\":\"#44596b\",\"activityBarBadge.foreground\":\"#ffffff\",\"sideBar.background\":\"#011627\",\"sideBar.foreground\":\"#89a4bb\",\"sideBar.border\":\"#011627\",\"sideBarTitle.foreground\":\"#5f7e97\",\"sideBarSectionHeader.background\":\"#011627\",\"sideBarSectionHeader.foreground\":\"#5f7e97\",\"editorCursor.foreground\":\"#80a4c2\",\"editor.wordHighlightBackground\":\"#f6bbe533\",\"editor.wordHighlightStrongBackground\":\"#e2a2f433\",\"editor.lineHighlightBackground\":\"#0003\",\"editor.rangeHighlightBackground\":\"#7e57c25a\",\"editorIndentGuide.background\":\"#5e81ce52\",\"editorIndentGuide.activeBackground\":\"#7e97ac\",\"editorRuler.foreground\":\"#5e81ce52\",\"editorCodeLens.foreground\":\"#5e82ceb4\",\"editorBracketMatch.background\":\"#5f7e974d\",\"editorOverviewRuler.currentContentForeground\":\"#7e57c2\",\"editorOverviewRuler.incomingContentForeground\":\"#7e57c2\",\"editorOverviewRuler.commonContentForeground\":\"#7e57c2\",\"editorGutter.background\":\"#011627\",\"editorGutter.modifiedBackground\":\"#e2b93d\",\"editorGutter.addedBackground\":\"#9ccc65\",\"editorGutter.deletedBackground\":\"#ef5350\",\"editorSuggestWidget.background\":\"#2c3043\",\"editorSuggestWidget.border\":\"#2b2f40\",\"editorSuggestWidget.foreground\":\"#d6deeb\",\"editorSuggestWidget.highlightForeground\":\"#ffffff\",\"editorSuggestWidget.selectedBackground\":\"#5f7e97\",\"debugExceptionWidget.background\":\"#011627\",\"debugExceptionWidget.border\":\"#5f7e97\",\"editorMarkerNavigation.background\":\"#0b2942\",\"editorMarkerNavigationError.background\":\"#ef5350\",\"editorMarkerNavigationWarning.background\":\"#ffca28\",\"peekView.border\":\"#5f7e97\",\"peekViewEditor.background\":\"#011627\",\"peekViewEditor.matchHighlightBackground\":\"#7e57c25a\",\"peekViewResult.background\":\"#011627\",\"peekViewResult.fileForeground\":\"#5f7e97\",\"peekViewResult.lineForeground\":\"#5f7e97\",\"peekViewResult.matchHighlightBackground\":\"#ffffffcc\",\"peekViewResult.selectionBackground\":\"#2e3250\",\"peekViewResult.selectionForeground\":\"#5f7e97\",\"peekViewTitle.background\":\"#011627\",\"peekViewTitleDescription.foreground\":\"#697098\",\"peekViewTitleLabel.foreground\":\"#5f7e97\",\"merge.currentHeaderBackground\":\"#5f7e97\",\"merge.incomingHeaderBackground\":\"#7e57c25a\",\"statusBar.background\":\"#011627\",\"statusBar.foreground\":\"#5f7e97\",\"statusBar.border\":\"#262a39\",\"statusBar.debuggingBackground\":\"#202431\",\"statusBar.debuggingBorder\":\"#1f2330\",\"statusBar.noFolderBackground\":\"#011627\",\"statusBar.noFolderBorder\":\"#25293a\",\"statusBarItem.activeBackground\":\"#202431\",\"statusBarItem.hoverBackground\":\"#202431\",\"statusBarItem.prominentBackground\":\"#202431\",\"statusBarItem.prominentHoverBackground\":\"#202431\",\"notifications.background\":\"#01111d\",\"notifications.border\":\"#262a39\",\"notificationCenter.border\":\"#262a39\",\"notificationToast.border\":\"#262a39\",\"notifications.foreground\":\"#ffffffcc\",\"notificationLink.foreground\":\"#80cbc4\",\"extensionButton.prominentForeground\":\"#ffffffcc\",\"extensionButton.prominentBackground\":\"#7e57c2cc\",\"extensionButton.prominentHoverBackground\":\"#7e57c2\",\"terminal.selectionBackground\":\"#1b90dd4d\",\"terminalCursor.background\":\"#234d70\",\"debugToolBar.background\":\"#011627\",\"welcomePage.buttonBackground\":\"#011627\",\"welcomePage.buttonHoverBackground\":\"#011627\",\"walkThrough.embeddedEditorBackground\":\"#011627\",\"gitDecoration.modifiedResourceForeground\":\"#a2bffc\",\"gitDecoration.deletedResourceForeground\":\"#ef535090\",\"gitDecoration.untrackedResourceForeground\":\"#c5e478ff\",\"gitDecoration.ignoredResourceForeground\":\"#395a75\",\"gitDecoration.conflictingResourceForeground\":\"#ffeb95cc\",\"source.elm\":\"#5f7e97\",\"string.quoted.single.js\":\"#ffffff\",\"meta.objectliteral.js\":\"#82aaff\"},\"fg\":\"#d6deeb\",\"bg\":\"#23262f\",\"semanticHighlighting\":false,\"settings\":[{\"name\":\"Changed\",\"scope\":[\"markup.changed\",\"meta.diff.header.git\",\"meta.diff.header.from-file\",\"meta.diff.header.to-file\"],\"settings\":{\"foreground\":\"#a2bffc\"}},{\"name\":\"Deleted\",\"scope\":[\"markup.deleted.diff\"],\"settings\":{\"foreground\":\"#f27775fe\"}},{\"name\":\"Inserted\",\"scope\":[\"markup.inserted.diff\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Global settings\",\"settings\":{\"background\":\"#011627\",\"foreground\":\"#d6deeb\"}},{\"name\":\"Comment\",\"scope\":[\"comment\"],\"settings\":{\"foreground\":\"#919f9f\",\"fontStyle\":\"\"}},{\"name\":\"String\",\"scope\":[\"string\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"String Quoted\",\"scope\":[\"string.quoted\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"Support Constant Math\",\"scope\":[\"support.constant.math\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Number\",\"scope\":[\"constant.numeric\",\"constant.character.numeric\"],\"settings\":{\"foreground\":\"#f78c6c\",\"fontStyle\":\"\"}},{\"name\":\"Built-in constant\",\"scope\":[\"constant.language\",\"punctuation.definition.constant\",\"variable.other.constant\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"User-defined constant\",\"scope\":[\"constant.character\",\"constant.other\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Constant Character Escape\",\"scope\":[\"constant.character.escape\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"RegExp String\",\"scope\":[\"string.regexp\",\"string.regexp keyword.other\"],\"settings\":{\"foreground\":\"#5ca7e4\"}},{\"name\":\"Comma in functions\",\"scope\":[\"meta.function punctuation.separator.comma\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"Variable\",\"scope\":[\"variable\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Keyword\",\"scope\":[\"punctuation.accessor\",\"keyword\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Storage\",\"scope\":[\"storage\",\"meta.var.expr\",\"meta.class meta.method.declaration meta.var.expr storage.type.js\",\"storage.type.property.js\",\"storage.type.property.ts\",\"storage.type.property.tsx\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type.function.arrow.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Class name\",\"scope\":[\"entity.name.class\",\"meta.class entity.name.type.class\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Inherited class\",\"scope\":[\"entity.other.inherited-class\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Function name\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Meta Tag\",\"scope\":[\"punctuation.definition.tag\",\"meta.tag\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"HTML Tag names\",\"scope\":[\"entity.name.tag\",\"meta.tag.other.html\",\"meta.tag.other.js\",\"meta.tag.other.tsx\",\"entity.name.tag.tsx\",\"entity.name.tag.js\",\"entity.name.tag\",\"meta.tag.js\",\"meta.tag.tsx\",\"meta.tag.html\"],\"settings\":{\"foreground\":\"#caece6\",\"fontStyle\":\"\"}},{\"name\":\"Tag attribute\",\"scope\":[\"entity.other.attribute-name\"],\"settings\":{\"fontStyle\":\"\",\"foreground\":\"#c5e478\"}},{\"name\":\"Entity Name Tag Custom\",\"scope\":[\"entity.name.tag.custom\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Library (function & constant)\",\"scope\":[\"support.function\",\"support.constant\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Support Constant Property Value meta\",\"scope\":[\"support.constant.meta.property-value\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Library class/type\",\"scope\":[\"support.type\",\"support.class\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Support Variable DOM\",\"scope\":[\"support.variable.dom\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Invalid\",\"scope\":[\"invalid\"],\"settings\":{\"background\":\"#ff2c83\",\"foreground\":\"#ffffff\"}},{\"name\":\"Invalid deprecated\",\"scope\":[\"invalid.deprecated\"],\"settings\":{\"foreground\":\"#ffffff\",\"background\":\"#d3423e\"}},{\"name\":\"Keyword Operator\",\"scope\":[\"keyword.operator\"],\"settings\":{\"foreground\":\"#7fdbca\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Relational\",\"scope\":[\"keyword.operator.relational\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Assignment\",\"scope\":[\"keyword.operator.assignment\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Arithmetic\",\"scope\":[\"keyword.operator.arithmetic\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Bitwise\",\"scope\":[\"keyword.operator.bitwise\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Increment\",\"scope\":[\"keyword.operator.increment\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Keyword Operator Ternary\",\"scope\":[\"keyword.operator.ternary\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Double-Slashed Comment\",\"scope\":[\"comment.line.double-slash\"],\"settings\":{\"foreground\":\"#919f9f\"}},{\"name\":\"Object\",\"scope\":[\"object\"],\"settings\":{\"foreground\":\"#cdebf7\"}},{\"name\":\"Null\",\"scope\":[\"constant.language.null\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Meta Brace\",\"scope\":[\"meta.brace\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Meta Delimiter Period\",\"scope\":[\"meta.delimiter.period\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Punctuation Definition String\",\"scope\":[\"punctuation.definition.string\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Punctuation Definition String Markdown\",\"scope\":[\"punctuation.definition.string.begin.markdown\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Boolean\",\"scope\":[\"constant.language.boolean\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Object Comma\",\"scope\":[\"object.comma\"],\"settings\":{\"foreground\":\"#ffffff\"}},{\"name\":\"Variable Parameter Function\",\"scope\":[\"variable.parameter.function\"],\"settings\":{\"foreground\":\"#7fdbca\",\"fontStyle\":\"\"}},{\"name\":\"Support Type Property Name & entity name tags\",\"scope\":[\"support.type.vendor.property-name\",\"support.constant.vendor.property-value\",\"support.type.property-name\",\"meta.property-list entity.name.tag\"],\"settings\":{\"foreground\":\"#80cbc4\",\"fontStyle\":\"\"}},{\"name\":\"Entity Name tag reference in stylesheets\",\"scope\":[\"meta.property-list entity.name.tag.reference\"],\"settings\":{\"foreground\":\"#57eaf1\"}},{\"name\":\"Constant Other Color RGB Value Punctuation Definition Constant\",\"scope\":[\"constant.other.color.rgb-value punctuation.definition.constant\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Constant Other Color\",\"scope\":[\"constant.other.color\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Keyword Other Unit\",\"scope\":[\"keyword.other.unit\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Meta Selector\",\"scope\":[\"meta.selector\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Entity Other Attribute Name Id\",\"scope\":[\"entity.other.attribute-name.id\"],\"settings\":{\"foreground\":\"#fad430\"}},{\"name\":\"Meta Property Name\",\"scope\":[\"meta.property-name\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Doctypes\",\"scope\":[\"entity.name.tag.doctype\",\"meta.tag.sgml.doctype\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Punctuation Definition Parameters\",\"scope\":[\"punctuation.definition.parameters\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Keyword Control Operator\",\"scope\":[\"keyword.control.operator\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Keyword Operator Logical\",\"scope\":[\"keyword.operator.logical\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Variable Instances\",\"scope\":[\"variable.instance\",\"variable.other.instance\",\"variable.readwrite.instance\",\"variable.other.readwrite.instance\",\"variable.other.property\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Variable Property Other object property\",\"scope\":[\"variable.other.object.property\"],\"settings\":{\"foreground\":\"#faf39f\",\"fontStyle\":\"\"}},{\"name\":\"Variable Property Other object\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Entity Name Function\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#82aaff\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Comparison, returns, imports, and Keyword Operator Ruby\",\"scope\":[\"keyword.control.conditional.js\",\"keyword.operator.comparison\",\"keyword.control.flow.js\",\"keyword.control.flow.ts\",\"keyword.control.flow.tsx\",\"keyword.control.ruby\",\"keyword.control.def.ruby\",\"keyword.control.loop.js\",\"keyword.control.loop.ts\",\"keyword.control.import.js\",\"keyword.control.import.ts\",\"keyword.control.import.tsx\",\"keyword.control.from.js\",\"keyword.control.from.ts\",\"keyword.control.from.tsx\",\"keyword.control.conditional.js\",\"keyword.control.conditional.ts\",\"keyword.control.switch.js\",\"keyword.control.switch.ts\",\"keyword.operator.instanceof.js\",\"keyword.operator.expression.instanceof.ts\",\"keyword.operator.expression.instanceof.tsx\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Support Constant, `new` keyword, Special Method Keyword, `debugger`, other keywords\",\"scope\":[\"support.constant\",\"keyword.other.special-method\",\"keyword.other.new\",\"keyword.other.debugger\",\"keyword.control\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Support Function\",\"scope\":[\"support.function\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Invalid Broken\",\"scope\":[\"invalid.broken\"],\"settings\":{\"foreground\":\"#989da0\",\"background\":\"#F78C6C\"}},{\"name\":\"Invalid Unimplemented\",\"scope\":[\"invalid.unimplemented\"],\"settings\":{\"background\":\"#8BD649\",\"foreground\":\"#ffffff\"}},{\"name\":\"Invalid Illegal\",\"scope\":[\"invalid.illegal\"],\"settings\":{\"foreground\":\"#ffffff\",\"background\":\"#ec5f67\"}},{\"name\":\"Language Variable\",\"scope\":[\"variable.language\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Support Variable Property\",\"scope\":[\"support.variable.property\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Variable Function\",\"scope\":[\"variable.function\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Variable Interpolation\",\"scope\":[\"variable.interpolation\"],\"settings\":{\"foreground\":\"#ef787f\"}},{\"name\":\"Meta Function Call\",\"scope\":[\"meta.function-call\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Punctuation Section Embedded\",\"scope\":[\"punctuation.section.embedded\"],\"settings\":{\"foreground\":\"#e2817f\"}},{\"name\":\"Punctuation Tweaks\",\"scope\":[\"punctuation.terminator.expression\",\"punctuation.definition.arguments\",\"punctuation.definition.array\",\"punctuation.section.array\",\"meta.array\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"More Punctuation Tweaks\",\"scope\":[\"punctuation.definition.list.begin\",\"punctuation.definition.list.end\",\"punctuation.separator.arguments\",\"punctuation.definition.list\"],\"settings\":{\"foreground\":\"#d9f5dd\"}},{\"name\":\"Template Strings\",\"scope\":[\"string.template meta.template.expression\"],\"settings\":{\"foreground\":\"#e2817f\"}},{\"name\":\"Backticks(``) in Template Strings\",\"scope\":[\"string.template punctuation.definition.string\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Italics\",\"scope\":[\"italic\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"italic\"}},{\"name\":\"Bold\",\"scope\":[\"bold\"],\"settings\":{\"foreground\":\"#c5e478\",\"fontStyle\":\"bold\"}},{\"name\":\"Quote\",\"scope\":[\"quote\"],\"settings\":{\"foreground\":\"#969bb7\",\"fontStyle\":\"\"}},{\"name\":\"Raw Code\",\"scope\":[\"raw\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"CoffeeScript Variable Assignment\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#31e1eb\"}},{\"name\":\"CoffeeScript Parameter Function\",\"scope\":[\"variable.parameter.function.coffee\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"CoffeeScript Assignments\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"C# Readwrite Variables\",\"scope\":[\"variable.other.readwrite.cs\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C# Classes & Storage types\",\"scope\":[\"entity.name.type.class.cs\",\"storage.type.cs\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"C# Namespaces\",\"scope\":[\"entity.name.type.namespace.cs\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"C# Unquoted String Zone\",\"scope\":[\"string.unquoted.preprocessor.message.cs\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C# Region\",\"scope\":[\"punctuation.separator.hash.cs\",\"keyword.preprocessor.region.cs\",\"keyword.preprocessor.endregion.cs\"],\"settings\":{\"foreground\":\"#ffcb8b\",\"fontStyle\":\"bold\"}},{\"name\":\"C# Other Variables\",\"scope\":[\"variable.other.object.cs\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"C# Enum\",\"scope\":[\"entity.name.type.enum.cs\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Dart String\",\"scope\":[\"string.interpolated.single.dart\",\"string.interpolated.double.dart\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Dart Class\",\"scope\":[\"support.class.dart\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Tag names in Stylesheets\",\"scope\":[\"entity.name.tag.css\",\"entity.name.tag.less\",\"entity.name.tag.custom.css\",\"support.constant.property-value.css\"],\"settings\":{\"foreground\":\"#ff6d6d\",\"fontStyle\":\"\"}},{\"name\":\"Wildcard(*) selector in Stylesheets\",\"scope\":[\"entity.name.tag.wildcard.css\",\"entity.name.tag.wildcard.less\",\"entity.name.tag.wildcard.scss\",\"entity.name.tag.wildcard.sass\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"CSS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Attribute Name for CSS\",\"scope\":[\"meta.attribute-selector.css entity.other.attribute-name.attribute\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Elixir Classes\",\"scope\":[\"source.elixir support.type.elixir\",\"source.elixir meta.module.elixir entity.name.class.elixir\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Elixir Functions\",\"scope\":[\"source.elixir entity.name.function\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir Constants\",\"scope\":[\"source.elixir constant.other.symbol.elixir\",\"source.elixir constant.other.keywords.elixir\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Elixir String Punctuations\",\"scope\":[\"source.elixir punctuation.definition.string\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir\",\"scope\":[\"source.elixir variable.other.readwrite.module.elixir\",\"source.elixir variable.other.readwrite.module.elixir punctuation.definition.variable.elixir\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Elixir Binary Punctuations\",\"scope\":[\"source.elixir .punctuation.binary.elixir\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"Closure Constant Keyword\",\"scope\":[\"constant.keyword.clojure\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Go Function Calls\",\"scope\":[\"source.go meta.function-call.go\"],\"settings\":{\"foreground\":\"#dddddd\"}},{\"name\":\"Go Keywords\",\"scope\":[\"source.go keyword.package.go\",\"source.go keyword.import.go\",\"source.go keyword.function.go\",\"source.go keyword.type.go\",\"source.go keyword.struct.go\",\"source.go keyword.interface.go\",\"source.go keyword.const.go\",\"source.go keyword.var.go\",\"source.go keyword.map.go\",\"source.go keyword.channel.go\",\"source.go keyword.control.go\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"Go Constants e.g. nil, string format (%s, %d, etc.)\",\"scope\":[\"source.go constant.language.go\",\"source.go constant.other.placeholder.go\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"C++ Functions\",\"scope\":[\"entity.name.function.preprocessor.cpp\",\"entity.scope.name.cpp\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"C++ Meta Namespace\",\"scope\":[\"meta.namespace-block.cpp\"],\"settings\":{\"foreground\":\"#e0dec6\"}},{\"name\":\"C++ Language Primitive Storage\",\"scope\":[\"storage.type.language.primitive.cpp\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"C++ Preprocessor Macro\",\"scope\":[\"meta.preprocessor.macro.cpp\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"C++ Variable Parameter\",\"scope\":[\"variable.parameter\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Powershell Variables\",\"scope\":[\"variable.other.readwrite.powershell\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Powershell Function\",\"scope\":[\"support.function.powershell\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"ID Attribute Name in HTML\",\"scope\":[\"entity.other.attribute-name.id.html\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"HTML Punctuation Definition Tag\",\"scope\":[\"punctuation.definition.tag.html\"],\"settings\":{\"foreground\":\"#6ae9f0\"}},{\"name\":\"HTML Doctype\",\"scope\":[\"meta.tag.sgml.doctype.html\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Classes\",\"scope\":[\"meta.class entity.name.type.class.js\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"JavaScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.js\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"JavaScript Terminator\",\"scope\":[\"terminator.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Meta Punctuation Definition\",\"scope\":[\"meta.js punctuation.definition.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Entity Names in Code Documentations\",\"scope\":[\"entity.name.type.instance.jsdoc\",\"entity.name.type.instance.phpdoc\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"Other Variables in Code Documentations\",\"scope\":[\"variable.other.jsdoc\",\"variable.other.phpdoc\"],\"settings\":{\"foreground\":\"#78ccf0\"}},{\"name\":\"JavaScript module imports and exports\",\"scope\":[\"variable.other.meta.import.js\",\"meta.import.js variable.other\",\"variable.other.meta.export.js\",\"meta.export.js variable.other\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Variable Parameter Function\",\"scope\":[\"variable.parameter.function.js\"],\"settings\":{\"foreground\":\"#8b96ea\"}},{\"name\":\"JavaScript[React] Variable Other Object\",\"scope\":[\"variable.other.object.js\",\"variable.other.object.jsx\",\"variable.object.property.js\",\"variable.object.property.jsx\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Variables\",\"scope\":[\"variable.js\",\"variable.other.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JavaScript Entity Name Type\",\"scope\":[\"entity.name.type.js\",\"entity.name.type.module.js\"],\"settings\":{\"foreground\":\"#ffcb8b\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Support Classes\",\"scope\":[\"support.class.js\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"JSON Property Names\",\"scope\":[\"support.type.property-name.json\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"JSON Support Constants\",\"scope\":[\"support.constant.json\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"JSON Property values (string)\",\"scope\":[\"meta.structure.dictionary.value.json string.quoted.double\"],\"settings\":{\"foreground\":\"#c789d6\"}},{\"name\":\"Strings in JSON values\",\"scope\":[\"string.quoted.double.json punctuation.definition.string.json\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Specific JSON Property values like null\",\"scope\":[\"meta.structure.dictionary.json meta.structure.dictionary.value constant.language\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"JavaScript Other Variable\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Ruby Variables\",\"scope\":[\"variable.other.ruby\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Ruby Class\",\"scope\":[\"entity.name.type.class.ruby\"],\"settings\":{\"foreground\":\"#ecc48d\"}},{\"name\":\"Ruby Hashkeys\",\"scope\":[\"constant.language.symbol.hashkey.ruby\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"LESS Tag names\",\"scope\":[\"entity.name.tag.less\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"LESS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"Attribute Name for LESS\",\"scope\":[\"meta.attribute-selector.less entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Markdown Headings\",\"scope\":[\"markup.heading.markdown\",\"markup.heading.setext.1.markdown\",\"markup.heading.setext.2.markdown\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown Italics\",\"scope\":[\"markup.italic.markdown\"],\"settings\":{\"foreground\":\"#c792ea\",\"fontStyle\":\"italic\"}},{\"name\":\"Markdown Bold\",\"scope\":[\"markup.bold.markdown\"],\"settings\":{\"foreground\":\"#c5e478\",\"fontStyle\":\"bold\"}},{\"name\":\"Markdown Quote + others\",\"scope\":[\"markup.quote.markdown\"],\"settings\":{\"foreground\":\"#969bb7\",\"fontStyle\":\"\"}},{\"name\":\"Markdown Raw Code + others\",\"scope\":[\"markup.inline.raw.markdown\"],\"settings\":{\"foreground\":\"#80cbc4\"}},{\"name\":\"Markdown Links\",\"scope\":[\"markup.underline.link.markdown\",\"markup.underline.link.image.markdown\"],\"settings\":{\"foreground\":\"#ff869a\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Link Title and Description\",\"scope\":[\"string.other.link.title.markdown\",\"string.other.link.description.markdown\"],\"settings\":{\"foreground\":\"#d6deeb\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Punctuation\",\"scope\":[\"punctuation.definition.string.markdown\",\"punctuation.definition.string.begin.markdown\",\"punctuation.definition.string.end.markdown\",\"meta.link.inline.markdown punctuation.definition.string\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown MetaData Punctuation\",\"scope\":[\"punctuation.definition.metadata.markdown\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"Markdown List Punctuation\",\"scope\":[\"beginning.punctuation.definition.list.markdown\"],\"settings\":{\"foreground\":\"#82b1ff\"}},{\"name\":\"Markdown Inline Raw String\",\"scope\":[\"markup.inline.raw.string.markdown\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"PHP Variables\",\"scope\":[\"variable.other.php\"],\"settings\":{\"foreground\":\"#bec5d4\"}},{\"name\":\"Support Classes in PHP\",\"scope\":[\"support.class.php\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"Punctuations in PHP function calls\",\"scope\":[\"meta.function-call.php punctuation\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"PHP Global Variables\",\"scope\":[\"variable.other.global.php\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Declaration Punctuation in PHP Global Variables\",\"scope\":[\"variable.other.global.php punctuation.definition.variable\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Language Constants in Python\",\"scope\":[\"constant.language.python\"],\"settings\":{\"foreground\":\"#ff6a83\"}},{\"name\":\"Python Function Parameter and Arguments\",\"scope\":[\"variable.parameter.function.python\",\"meta.function-call.arguments.python\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Python Function Call\",\"scope\":[\"meta.function-call.python\",\"meta.function-call.generic.python\"],\"settings\":{\"foreground\":\"#b2ccd6\"}},{\"name\":\"Punctuations in Python\",\"scope\":[\"punctuation.python\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"Decorator Functions in Python\",\"scope\":[\"entity.name.function.decorator.python\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Python Language Variable\",\"scope\":[\"source.python variable.language.special\"],\"settings\":{\"foreground\":\"#8eace3\"}},{\"name\":\"Python import control keyword\",\"scope\":[\"keyword.control\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"SCSS Variable\",\"scope\":[\"variable.scss\",\"variable.sass\",\"variable.parameter.url.scss\",\"variable.parameter.url.sass\"],\"settings\":{\"foreground\":\"#c5e478\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#bec5d4\"}},{\"name\":\"Attribute Name for SASS\",\"scope\":[\"meta.attribute-selector.scss entity.other.attribute-name.attribute\",\"meta.attribute-selector.sass entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#f78c6c\"}},{\"name\":\"Tag names in SASS\",\"scope\":[\"entity.name.tag.scss\",\"entity.name.tag.sass\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"SASS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.scss\",\"keyword.other.unit.sass\"],\"settings\":{\"foreground\":\"#ffeb95\"}},{\"name\":\"TypeScript[React] Variables and Object Properties\",\"scope\":[\"variable.other.readwrite.alias.ts\",\"variable.other.readwrite.alias.tsx\",\"variable.other.readwrite.ts\",\"variable.other.readwrite.tsx\",\"variable.other.object.ts\",\"variable.other.object.tsx\",\"variable.object.property.ts\",\"variable.object.property.tsx\",\"variable.other.ts\",\"variable.other.tsx\",\"variable.tsx\",\"variable.ts\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript[React] Entity Name Types\",\"scope\":[\"entity.name.type.ts\",\"entity.name.type.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript[React] Node Classes\",\"scope\":[\"support.class.node.ts\",\"support.class.node.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"TypeScript[React] Entity Name Types as Parameters\",\"scope\":[\"meta.type.parameters.ts entity.name.type\",\"meta.type.parameters.tsx entity.name.type\"],\"settings\":{\"foreground\":\"#889fb2\"}},{\"name\":\"TypeScript[React] Import/Export Punctuations\",\"scope\":[\"meta.import.ts punctuation.definition.block\",\"meta.import.tsx punctuation.definition.block\",\"meta.export.ts punctuation.definition.block\",\"meta.export.tsx punctuation.definition.block\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.decorator punctuation.decorator.ts\",\"meta.decorator punctuation.decorator.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.tag.js meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"YAML Entity Name Tags\",\"scope\":[\"entity.name.tag.yaml\"],\"settings\":{\"foreground\":\"#7fdbca\"}},{\"name\":\"JavaScript Variable Other ReadWrite\",\"scope\":[\"variable.other.readwrite.js\",\"variable.parameter\"],\"settings\":{\"foreground\":\"#d7dbe0\"}},{\"name\":\"Support Class Component\",\"scope\":[\"support.class.component.js\",\"support.class.component.tsx\"],\"settings\":{\"foreground\":\"#f78c6c\",\"fontStyle\":\"\"}},{\"name\":\"Text nested in React tags\",\"scope\":[\"meta.jsx.children\",\"meta.jsx.children.js\",\"meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#d6deeb\"}},{\"name\":\"TypeScript Classes\",\"scope\":[\"meta.class entity.name.type.class.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript Entity Name Type\",\"scope\":[\"entity.name.type.tsx\",\"entity.name.type.module.tsx\"],\"settings\":{\"foreground\":\"#ffcb8b\"}},{\"name\":\"TypeScript Class Variable Keyword\",\"scope\":[\"meta.class.ts meta.var.expr.ts storage.type.ts\",\"meta.class.tsx meta.var.expr.tsx storage.type.tsx\"],\"settings\":{\"foreground\":\"#c792ea\"}},{\"name\":\"TypeScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.ts\",\"meta.method.declaration storage.type.tsx\"],\"settings\":{\"foreground\":\"#82aaff\"}},{\"name\":\"normalize font style of certain components\",\"scope\":[\"meta.property-list.css meta.property-value.css variable.other.less\",\"meta.property-list.scss variable.scss\",\"meta.property-list.sass variable.sass\",\"meta.brace\",\"keyword.operator.operator\",\"keyword.operator.or.regexp\",\"keyword.operator.expression.in\",\"keyword.operator.relational\",\"keyword.operator.assignment\",\"keyword.operator.comparison\",\"keyword.operator.type\",\"keyword.operator\",\"keyword\",\"punctuation.definition.string\",\"punctuation\",\"variable.other.readwrite.js\",\"storage.type\",\"source.css\",\"string.quoted\"],\"settings\":{\"fontStyle\":\"\"}}],\"styleOverrides\":{\"frames\":{\"editorBackground\":\"var(--sl-color-gray-6)\",\"terminalBackground\":\"var(--sl-color-gray-6)\",\"editorActiveTabBackground\":\"var(--sl-color-gray-6)\",\"terminalTitlebarDotsForeground\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"terminalTitlebarDotsOpacity\":\"0.75\",\"inlineButtonForeground\":\"var(--sl-color-text)\",\"frameBoxShadowCssValue\":\"none\"},\"textMarkers\":{\"markBackground\":\"#ffffff17\",\"markBorderColor\":\"#ffffff40\"}}},{\"name\":\"Night Owl Light\",\"type\":\"light\",\"colors\":{\"focusBorder\":\"#93a1a1\",\"foreground\":\"#403f53\",\"disabledForeground\":\"#61616180\",\"descriptionForeground\":\"#403f53\",\"errorForeground\":\"#403f53\",\"icon.foreground\":\"#424242\",\"contrastActiveBorder\":null,\"contrastBorder\":null,\"textBlockQuote.background\":\"#7f7f7f1a\",\"textBlockQuote.border\":\"#007acc80\",\"textCodeBlock.background\":\"#dcdcdc66\",\"textLink.activeForeground\":\"#006ab1\",\"textLink.foreground\":\"#006ab1\",\"textPreformat.foreground\":\"#a31515\",\"textSeparator.foreground\":\"#0000002e\",\"editor.background\":\"#f6f7f9\",\"editor.foreground\":\"#403f53\",\"editorLineNumber.foreground\":\"#90a7b2\",\"editorLineNumber.activeForeground\":\"#403f53\",\"editorActiveLineNumber.foreground\":\"#0b216f\",\"editor.selectionBackground\":\"#e0e0e0\",\"editor.inactiveSelectionBackground\":\"#e0e0e080\",\"editor.selectionHighlightBackground\":\"#339cec33\",\"editorError.foreground\":\"#e64d49\",\"editorWarning.foreground\":\"#daaa01\",\"editorInfo.foreground\":\"#1a85ff\",\"editorHint.foreground\":\"#6c6c6c\",\"problemsErrorIcon.foreground\":\"#e64d49\",\"problemsWarningIcon.foreground\":\"#daaa01\",\"problemsInfoIcon.foreground\":\"#1a85ff\",\"editor.findMatchBackground\":\"#93a1a16c\",\"editor.findMatchHighlightBackground\":\"#93a1a16c\",\"editor.findRangeHighlightBackground\":\"#7497a633\",\"editorLink.activeForeground\":\"#0000ff\",\"editorLightBulb.foreground\":\"#ddb100\",\"editorLightBulbAutoFix.foreground\":\"#007acc\",\"diffEditor.insertedTextBackground\":\"#9ccc2c40\",\"diffEditor.insertedTextBorder\":null,\"diffEditor.removedTextBackground\":\"#ff000033\",\"diffEditor.removedTextBorder\":null,\"diffEditor.insertedLineBackground\":\"#9bb95533\",\"diffEditor.removedLineBackground\":\"#ff000033\",\"editorStickyScroll.background\":\"#fbfbfb\",\"editorStickyScrollHover.background\":\"#f0f0f0\",\"editorInlayHint.background\":\"#2aa29899\",\"editorInlayHint.foreground\":\"#f0f0f0\",\"editorInlayHint.typeBackground\":\"#2aa29899\",\"editorInlayHint.typeForeground\":\"#f0f0f0\",\"editorInlayHint.parameterBackground\":\"#2aa29899\",\"editorInlayHint.parameterForeground\":\"#f0f0f0\",\"editorPane.background\":\"#fbfbfb\",\"editorGroup.emptyBackground\":null,\"editorGroup.focusedEmptyBorder\":null,\"editorGroupHeader.tabsBackground\":\"var(--sl-color-gray-6)\",\"editorGroupHeader.tabsBorder\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"editorGroupHeader.noTabsBackground\":\"#f0f0f0\",\"editorGroupHeader.border\":null,\"editorGroup.border\":\"#f0f0f0\",\"editorGroup.dropBackground\":\"#2677cb2d\",\"editorGroup.dropIntoPromptForeground\":\"#403f53\",\"editorGroup.dropIntoPromptBackground\":\"#f0f0f0\",\"editorGroup.dropIntoPromptBorder\":null,\"sideBySideEditor.horizontalBorder\":\"#f0f0f0\",\"sideBySideEditor.verticalBorder\":\"#f0f0f0\",\"scrollbar.shadow\":\"#cccccc\",\"scrollbarSlider.background\":\"#0000001a\",\"scrollbarSlider.hoverBackground\":\"#00000055\",\"scrollbarSlider.activeBackground\":\"#00000099\",\"panel.background\":\"#f0f0f0\",\"panel.border\":\"#d9d9d9\",\"panelTitle.activeBorder\":\"#424242\",\"panelTitle.activeForeground\":\"#424242\",\"panelTitle.inactiveForeground\":\"#424242bf\",\"panelSectionHeader.background\":\"#80808051\",\"terminal.background\":\"#f6f6f6\",\"widget.shadow\":\"#d9d9d9\",\"editorWidget.background\":\"#f0f0f0\",\"editorWidget.foreground\":\"#403f53\",\"editorWidget.border\":\"#d9d9d9\",\"quickInput.background\":\"#f0f0f0\",\"quickInput.foreground\":\"#403f53\",\"quickInputTitle.background\":\"#0000000f\",\"pickerGroup.foreground\":\"#403f53\",\"pickerGroup.border\":\"#d9d9d9\",\"editor.hoverHighlightBackground\":\"#339cec33\",\"editorHoverWidget.background\":\"#f0f0f0\",\"editorHoverWidget.foreground\":\"#403f53\",\"editorHoverWidget.border\":\"#d9d9d9\",\"editorHoverWidget.statusBarBackground\":\"#e4e4e4\",\"titleBar.activeBackground\":\"var(--sl-color-gray-6)\",\"titleBar.activeForeground\":\"var(--sl-color-text)\",\"titleBar.inactiveBackground\":\"#f0f0f099\",\"titleBar.inactiveForeground\":\"#33333399\",\"titleBar.border\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"toolbar.hoverBackground\":\"#b8b8b850\",\"toolbar.activeBackground\":\"#a6a6a650\",\"tab.activeBackground\":\"#f6f6f6\",\"tab.unfocusedActiveBackground\":\"#f6f6f6\",\"tab.inactiveBackground\":\"#f0f0f0\",\"tab.unfocusedInactiveBackground\":\"#f0f0f0\",\"tab.activeForeground\":\"var(--sl-color-text)\",\"tab.inactiveForeground\":\"#403f53\",\"tab.unfocusedActiveForeground\":\"#403f53b3\",\"tab.unfocusedInactiveForeground\":\"#403f5380\",\"tab.hoverBackground\":null,\"tab.unfocusedHoverBackground\":null,\"tab.hoverForeground\":null,\"tab.unfocusedHoverForeground\":null,\"tab.border\":\"#f0f0f0\",\"tab.lastPinnedBorder\":\"#a9a9a9\",\"tab.activeBorder\":\"transparent\",\"tab.unfocusedActiveBorder\":null,\"tab.activeBorderTop\":\"var(--sl-color-accent)\",\"tab.unfocusedActiveBorderTop\":null,\"tab.hoverBorder\":null,\"tab.unfocusedHoverBorder\":null,\"tab.activeModifiedBorder\":\"#2aa298\",\"tab.inactiveModifiedBorder\":\"#93a1a1\",\"tab.unfocusedActiveModifiedBorder\":\"#93a1a1\",\"tab.unfocusedInactiveModifiedBorder\":\"#93a1a1\",\"badge.background\":\"#2aa298\",\"badge.foreground\":\"#f0f0f0\",\"button.background\":\"#2aa298\",\"button.foreground\":\"#f0f0f0\",\"button.border\":null,\"button.separator\":\"#f0f0f066\",\"button.hoverBackground\":\"#22827a\",\"button.secondaryBackground\":\"#5f6a79\",\"button.secondaryForeground\":\"#ffffff\",\"button.secondaryHoverBackground\":\"#4c5561\",\"dropdown.background\":\"#f0f0f0\",\"dropdown.foreground\":\"#403f53\",\"dropdown.border\":\"#d9d9d9\",\"list.activeSelectionBackground\":\"#d3e8f8\",\"list.activeSelectionForeground\":\"#403f53\",\"tree.indentGuidesStroke\":\"#a9a9a9\",\"input.background\":\"#f0f0f0\",\"input.foreground\":\"#403f53\",\"input.placeholderForeground\":\"#93a1a1\",\"inputOption.activeBorder\":\"#2aa298\",\"inputOption.hoverBackground\":\"#b8b8b850\",\"inputOption.activeBackground\":\"#93a1a133\",\"inputOption.activeForeground\":\"#000000\",\"inputValidation.infoBackground\":\"#f0f0f0\",\"inputValidation.infoBorder\":\"#d0d0d0\",\"inputValidation.warningBackground\":\"#daaa01\",\"inputValidation.warningBorder\":\"#e0af02\",\"inputValidation.errorBackground\":\"#f76e6e\",\"inputValidation.errorBorder\":\"#de3d3b\",\"keybindingLabel.background\":\"#dddddd66\",\"keybindingLabel.foreground\":\"#555555\",\"keybindingLabel.border\":\"#cccccc66\",\"keybindingLabel.bottomBorder\":\"#bbbbbb66\",\"menu.foreground\":\"#403f53\",\"menu.background\":\"#f0f0f0\",\"menu.selectionForeground\":\"#403f53\",\"menu.selectionBackground\":\"#d3e8f8\",\"menu.separatorBackground\":\"#d4d4d4\",\"editor.snippetTabstopHighlightBackground\":\"#0a326433\",\"editor.snippetFinalTabstopHighlightBorder\":\"#0a326480\",\"terminal.ansiBlack\":\"#403f53\",\"terminal.ansiRed\":\"#de3d3b\",\"terminal.ansiGreen\":\"#08916a\",\"terminal.ansiYellow\":\"#e0af02\",\"terminal.ansiBlue\":\"#288ed7\",\"terminal.ansiMagenta\":\"#d6438a\",\"terminal.ansiCyan\":\"#2aa298\",\"terminal.ansiWhite\":\"#f0f0f0\",\"terminal.ansiBrightBlack\":\"#403f53\",\"terminal.ansiBrightRed\":\"#de3d3b\",\"terminal.ansiBrightGreen\":\"#08916a\",\"terminal.ansiBrightYellow\":\"#daaa01\",\"terminal.ansiBrightBlue\":\"#288ed7\",\"terminal.ansiBrightMagenta\":\"#d6438a\",\"terminal.ansiBrightCyan\":\"#2aa298\",\"terminal.ansiBrightWhite\":\"#f0f0f0\",\"selection.background\":\"#7a8181ad\",\"notifications.background\":\"#f0f0f0\",\"notifications.foreground\":\"#403f53\",\"notificationLink.foreground\":\"#994cc3\",\"notifications.border\":\"#cccccc\",\"notificationCenter.border\":\"#cccccc\",\"notificationToast.border\":\"#cccccc\",\"notificationCenterHeader.foreground\":\"#403f53\",\"notificationCenterHeader.background\":\"#f0f0f0\",\"input.border\":\"#d9d9d9\",\"progressBar.background\":\"#2aa298\",\"list.inactiveSelectionBackground\":\"#e0e7ea\",\"list.inactiveSelectionForeground\":\"#403f53\",\"list.focusBackground\":\"#d3e8f8\",\"list.hoverBackground\":\"#d3e8f8\",\"list.focusForeground\":\"#403f53\",\"list.hoverForeground\":\"#403f53\",\"list.highlightForeground\":\"#403f53\",\"list.errorForeground\":\"#e64d49\",\"list.warningForeground\":\"#daaa01\",\"activityBar.background\":\"#f0f0f0\",\"activityBar.foreground\":\"#403f53\",\"activityBar.dropBackground\":\"#d0d0d0\",\"activityBarBadge.background\":\"#403f53\",\"activityBarBadge.foreground\":\"#f0f0f0\",\"activityBar.border\":\"#f0f0f0\",\"sideBar.background\":\"#f0f0f0\",\"sideBar.foreground\":\"#403f53\",\"sideBarTitle.foreground\":\"#403f53\",\"sideBar.border\":\"#f0f0f0\",\"editorGroup.background\":\"#f6f6f6\",\"editorCursor.foreground\":\"#90a7b2\",\"editor.wordHighlightBackground\":\"#339cec33\",\"editor.wordHighlightStrongBackground\":\"#007dd659\",\"editor.lineHighlightBackground\":\"#f0f0f0\",\"editor.rangeHighlightBackground\":\"#7497a633\",\"editorWhitespace.foreground\":\"#d9d9d9\",\"editorIndentGuide.background\":\"#d9d9d9\",\"editorCodeLens.foreground\":\"#403f53\",\"editorBracketMatch.background\":\"#d3e8f8\",\"editorBracketMatch.border\":\"#2aa298\",\"editorError.border\":\"#fbfbfb\",\"editorWarning.border\":\"#daaa01\",\"editorGutter.addedBackground\":\"#49d0c5\",\"editorGutter.modifiedBackground\":\"#6fbef6\",\"editorGutter.deletedBackground\":\"#f76e6e\",\"editorRuler.foreground\":\"#d9d9d9\",\"editorOverviewRuler.errorForeground\":\"#e64d49\",\"editorOverviewRuler.warningForeground\":\"#daaa01\",\"editorSuggestWidget.background\":\"#f0f0f0\",\"editorSuggestWidget.foreground\":\"#403f53\",\"editorSuggestWidget.highlightForeground\":\"#403f53\",\"editorSuggestWidget.selectedBackground\":\"#d3e8f8\",\"editorSuggestWidget.border\":\"#d9d9d9\",\"debugExceptionWidget.background\":\"#f0f0f0\",\"debugExceptionWidget.border\":\"#d9d9d9\",\"editorMarkerNavigation.background\":\"#d0d0d0\",\"editorMarkerNavigationError.background\":\"#f76e6e\",\"editorMarkerNavigationWarning.background\":\"#daaa01\",\"debugToolBar.background\":\"#f0f0f0\",\"extensionButton.prominentBackground\":\"#2aa298\",\"extensionButton.prominentForeground\":\"#f0f0f0\",\"statusBar.background\":\"#f0f0f0\",\"statusBar.border\":\"#f0f0f0\",\"statusBar.debuggingBackground\":\"#f0f0f0\",\"statusBar.debuggingForeground\":\"#403f53\",\"statusBar.foreground\":\"#403f53\",\"statusBar.noFolderBackground\":\"#f0f0f0\",\"statusBar.noFolderForeground\":\"#403f53\",\"peekView.border\":\"#d9d9d9\",\"peekViewEditor.background\":\"#f6f6f6\",\"peekViewEditorGutter.background\":\"#f6f6f6\",\"peekViewEditor.matchHighlightBackground\":\"#49d0c5\",\"peekViewResult.background\":\"#f0f0f0\",\"peekViewResult.fileForeground\":\"#403f53\",\"peekViewResult.lineForeground\":\"#403f53\",\"peekViewResult.matchHighlightBackground\":\"#49d0c5\",\"peekViewResult.selectionBackground\":\"#e0e7ea\",\"peekViewResult.selectionForeground\":\"#403f53\",\"peekViewTitle.background\":\"#f0f0f0\",\"peekViewTitleLabel.foreground\":\"#403f53\",\"peekViewTitleDescription.foreground\":\"#403f53\",\"terminal.foreground\":\"#403f53\"},\"fg\":\"#403f53\",\"bg\":\"#f6f7f9\",\"semanticHighlighting\":false,\"settings\":[{\"name\":\"Changed\",\"scope\":[\"markup.changed\",\"meta.diff.header.git\",\"meta.diff.header.from-file\",\"meta.diff.header.to-file\"],\"settings\":{\"foreground\":\"#556484\"}},{\"name\":\"Deleted\",\"scope\":[\"markup.deleted.diff\"],\"settings\":{\"foreground\":\"#ae3c3afd\"}},{\"name\":\"Inserted\",\"scope\":[\"markup.inserted.diff\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Global settings\",\"settings\":{\"background\":\"#011627\",\"foreground\":\"#403f53\"}},{\"name\":\"Comment\",\"scope\":[\"comment\"],\"settings\":{\"foreground\":\"#5f636f\"}},{\"name\":\"String\",\"scope\":[\"string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"String Quoted\",\"scope\":[\"string.quoted\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Support Constant Math\",\"scope\":[\"support.constant.math\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Number\",\"scope\":[\"constant.numeric\",\"constant.character.numeric\"],\"settings\":{\"foreground\":\"#aa0982\",\"fontStyle\":\"\"}},{\"name\":\"Built-in constant\",\"scope\":[\"constant.language\",\"punctuation.definition.constant\",\"variable.other.constant\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"User-defined constant\",\"scope\":[\"constant.character\",\"constant.other\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Constant Character Escape\",\"scope\":[\"constant.character.escape\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"RegExp String\",\"scope\":[\"string.regexp\",\"string.regexp keyword.other\"],\"settings\":{\"foreground\":\"#3a688f\"}},{\"name\":\"Comma in functions\",\"scope\":[\"meta.function punctuation.separator.comma\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"Variable\",\"scope\":[\"variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Keyword\",\"scope\":[\"punctuation.accessor\",\"keyword\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage\",\"scope\":[\"storage\",\"meta.var.expr\",\"meta.class meta.method.declaration meta.var.expr storage.type.js\",\"storage.type.property.js\",\"storage.type.property.ts\",\"storage.type.property.tsx\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Storage type\",\"scope\":[\"storage.type.function.arrow.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Class name\",\"scope\":[\"entity.name.class\",\"meta.class entity.name.type.class\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Inherited class\",\"scope\":[\"entity.other.inherited-class\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Function name\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Meta Tag\",\"scope\":[\"punctuation.definition.tag\",\"meta.tag\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"HTML Tag names\",\"scope\":[\"entity.name.tag\",\"meta.tag.other.html\",\"meta.tag.other.js\",\"meta.tag.other.tsx\",\"entity.name.tag.tsx\",\"entity.name.tag.js\",\"entity.name.tag\",\"meta.tag.js\",\"meta.tag.tsx\",\"meta.tag.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Tag attribute\",\"scope\":[\"entity.other.attribute-name\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Entity Name Tag Custom\",\"scope\":[\"entity.name.tag.custom\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Library (function & constant)\",\"scope\":[\"support.function\",\"support.constant\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Support Constant Property Value meta\",\"scope\":[\"support.constant.meta.property-value\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Library class/type\",\"scope\":[\"support.type\",\"support.class\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Support Variable DOM\",\"scope\":[\"support.variable.dom\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Invalid\",\"scope\":[\"invalid\"],\"settings\":{\"foreground\":\"#bb2060\"}},{\"name\":\"Invalid deprecated\",\"scope\":[\"invalid.deprecated\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Keyword Operator\",\"scope\":[\"keyword.operator\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Keyword Operator Relational\",\"scope\":[\"keyword.operator.relational\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Assignment\",\"scope\":[\"keyword.operator.assignment\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Arithmetic\",\"scope\":[\"keyword.operator.arithmetic\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Bitwise\",\"scope\":[\"keyword.operator.bitwise\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Increment\",\"scope\":[\"keyword.operator.increment\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Operator Ternary\",\"scope\":[\"keyword.operator.ternary\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Double-Slashed Comment\",\"scope\":[\"comment.line.double-slash\"],\"settings\":{\"foreground\":\"#5d6376\"}},{\"name\":\"Object\",\"scope\":[\"object\"],\"settings\":{\"foreground\":\"#58656a\"}},{\"name\":\"Null\",\"scope\":[\"constant.language.null\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Meta Brace\",\"scope\":[\"meta.brace\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Meta Delimiter Period\",\"scope\":[\"meta.delimiter.period\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Punctuation Definition String\",\"scope\":[\"punctuation.definition.string\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Punctuation Definition String Markdown\",\"scope\":[\"punctuation.definition.string.begin.markdown\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Boolean\",\"scope\":[\"constant.language.boolean\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Object Comma\",\"scope\":[\"object.comma\"],\"settings\":{\"foreground\":\"#646464\"}},{\"name\":\"Variable Parameter Function\",\"scope\":[\"variable.parameter.function\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Support Type Property Name & entity name tags\",\"scope\":[\"support.type.vendor.property-name\",\"support.constant.vendor.property-value\",\"support.type.property-name\",\"meta.property-list entity.name.tag\"],\"settings\":{\"foreground\":\"#096e72\",\"fontStyle\":\"\"}},{\"name\":\"Entity Name tag reference in stylesheets\",\"scope\":[\"meta.property-list entity.name.tag.reference\"],\"settings\":{\"foreground\":\"#286d70\"}},{\"name\":\"Constant Other Color RGB Value Punctuation Definition Constant\",\"scope\":[\"constant.other.color.rgb-value punctuation.definition.constant\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Constant Other Color\",\"scope\":[\"constant.other.color\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Keyword Other Unit\",\"scope\":[\"keyword.other.unit\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Meta Selector\",\"scope\":[\"meta.selector\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Entity Other Attribute Name Id\",\"scope\":[\"entity.other.attribute-name.id\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Meta Property Name\",\"scope\":[\"meta.property-name\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Doctypes\",\"scope\":[\"entity.name.tag.doctype\",\"meta.tag.sgml.doctype\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Punctuation Definition Parameters\",\"scope\":[\"punctuation.definition.parameters\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Keyword Control Operator\",\"scope\":[\"keyword.control.operator\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Keyword Operator Logical\",\"scope\":[\"keyword.operator.logical\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"\"}},{\"name\":\"Variable Instances\",\"scope\":[\"variable.instance\",\"variable.other.instance\",\"variable.readwrite.instance\",\"variable.other.readwrite.instance\",\"variable.other.property\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Variable Property Other object property\",\"scope\":[\"variable.other.object.property\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Variable Property Other object\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"fontStyle\":\"\"}},{\"name\":\"Entity Name Function\",\"scope\":[\"entity.name.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Keyword Operator Comparison, imports, returns and Keyword Operator Ruby\",\"scope\":[\"keyword.operator.comparison\",\"keyword.control.flow.js\",\"keyword.control.flow.ts\",\"keyword.control.flow.tsx\",\"keyword.control.ruby\",\"keyword.control.module.ruby\",\"keyword.control.class.ruby\",\"keyword.control.def.ruby\",\"keyword.control.loop.js\",\"keyword.control.loop.ts\",\"keyword.control.import.js\",\"keyword.control.import.ts\",\"keyword.control.import.tsx\",\"keyword.control.from.js\",\"keyword.control.from.ts\",\"keyword.control.from.tsx\",\"keyword.operator.instanceof.js\",\"keyword.operator.expression.instanceof.ts\",\"keyword.operator.expression.instanceof.tsx\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Keyword Control Conditional\",\"scope\":[\"keyword.control.conditional.js\",\"keyword.control.conditional.ts\",\"keyword.control.switch.js\",\"keyword.control.switch.ts\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"\"}},{\"name\":\"Support Constant, `new` keyword, Special Method Keyword, `debugger`, other keywords\",\"scope\":[\"support.constant\",\"keyword.other.special-method\",\"keyword.other.new\",\"keyword.other.debugger\",\"keyword.control\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Support Function\",\"scope\":[\"support.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Invalid Broken\",\"scope\":[\"invalid.broken\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Invalid Unimplemented\",\"scope\":[\"invalid.unimplemented\"],\"settings\":{\"foreground\":\"#486e26\"}},{\"name\":\"Invalid Illegal\",\"scope\":[\"invalid.illegal\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Language Variable\",\"scope\":[\"variable.language\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Support Variable Property\",\"scope\":[\"support.variable.property\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Variable Function\",\"scope\":[\"variable.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variable Interpolation\",\"scope\":[\"variable.interpolation\"],\"settings\":{\"foreground\":\"#a64348\"}},{\"name\":\"Meta Function Call\",\"scope\":[\"meta.function-call\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Punctuation Section Embedded\",\"scope\":[\"punctuation.section.embedded\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Punctuation Tweaks\",\"scope\":[\"punctuation.terminator.expression\",\"punctuation.definition.arguments\",\"punctuation.definition.array\",\"punctuation.section.array\",\"meta.array\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"More Punctuation Tweaks\",\"scope\":[\"punctuation.definition.list.begin\",\"punctuation.definition.list.end\",\"punctuation.separator.arguments\",\"punctuation.definition.list\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Template Strings\",\"scope\":[\"string.template meta.template.expression\"],\"settings\":{\"foreground\":\"#b23834\"}},{\"name\":\"Backticks(``) in Template Strings\",\"scope\":[\"string.template punctuation.definition.string\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Italics\",\"scope\":[\"italic\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"italic\"}},{\"name\":\"Bold\",\"scope\":[\"bold\"],\"settings\":{\"foreground\":\"#3b61b0\",\"fontStyle\":\"bold\"}},{\"name\":\"Quote\",\"scope\":[\"quote\"],\"settings\":{\"foreground\":\"#5c6285\"}},{\"name\":\"Raw Code\",\"scope\":[\"raw\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"CoffeeScript Variable Assignment\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#186e73\"}},{\"name\":\"CoffeeScript Parameter Function\",\"scope\":[\"variable.parameter.function.coffee\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"CoffeeScript Assignments\",\"scope\":[\"variable.assignment.coffee\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"C# Readwrite Variables\",\"scope\":[\"variable.other.readwrite.cs\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"C# Classes & Storage types\",\"scope\":[\"entity.name.type.class.cs\",\"storage.type.cs\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"C# Namespaces\",\"scope\":[\"entity.name.type.namespace.cs\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Tag names in Stylesheets\",\"scope\":[\"entity.name.tag.css\",\"entity.name.tag.less\",\"entity.name.tag.custom.css\",\"support.constant.property-value.css\"],\"settings\":{\"foreground\":\"#984e4d\",\"fontStyle\":\"\"}},{\"name\":\"Wildcard(*) selector in Stylesheets\",\"scope\":[\"entity.name.tag.wildcard.css\",\"entity.name.tag.wildcard.less\",\"entity.name.tag.wildcard.scss\",\"entity.name.tag.wildcard.sass\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"CSS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Attribute Name for CSS\",\"scope\":[\"meta.attribute-selector.css entity.other.attribute-name.attribute\",\"variable.other.readwrite.js\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Elixir Classes\",\"scope\":[\"source.elixir support.type.elixir\",\"source.elixir meta.module.elixir entity.name.class.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Functions\",\"scope\":[\"source.elixir entity.name.function\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Constants\",\"scope\":[\"source.elixir constant.other.symbol.elixir\",\"source.elixir constant.other.keywords.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir String Punctuations\",\"scope\":[\"source.elixir punctuation.definition.string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir\",\"scope\":[\"source.elixir variable.other.readwrite.module.elixir\",\"source.elixir variable.other.readwrite.module.elixir punctuation.definition.variable.elixir\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Elixir Binary Punctuations\",\"scope\":[\"source.elixir .punctuation.binary.elixir\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Closure Constant Keyword\",\"scope\":[\"constant.keyword.clojure\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Go Function Calls\",\"scope\":[\"source.go meta.function-call.go\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Go Keywords\",\"scope\":[\"source.go keyword.package.go\",\"source.go keyword.import.go\",\"source.go keyword.function.go\",\"source.go keyword.type.go\",\"source.go keyword.struct.go\",\"source.go keyword.interface.go\",\"source.go keyword.const.go\",\"source.go keyword.var.go\",\"source.go keyword.map.go\",\"source.go keyword.channel.go\",\"source.go keyword.control.go\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"Go Constants e.g. nil, string format (%s, %d, etc.)\",\"scope\":[\"source.go constant.language.go\",\"source.go constant.other.placeholder.go\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"C++ Functions\",\"scope\":[\"entity.name.function.preprocessor.cpp\",\"entity.scope.name.cpp\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"C++ Meta Namespace\",\"scope\":[\"meta.namespace-block.cpp\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"C++ Language Primitive Storage\",\"scope\":[\"storage.type.language.primitive.cpp\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"C++ Preprocessor Macro\",\"scope\":[\"meta.preprocessor.macro.cpp\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"C++ Variable Parameter\",\"scope\":[\"variable.parameter\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Powershell Variables\",\"scope\":[\"variable.other.readwrite.powershell\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Powershell Function\",\"scope\":[\"support.function.powershell\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"ID Attribute Name in HTML\",\"scope\":[\"entity.other.attribute-name.id.html\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"HTML Punctuation Definition Tag\",\"scope\":[\"punctuation.definition.tag.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"HTML Doctype\",\"scope\":[\"meta.tag.sgml.doctype.html\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"JavaScript Classes\",\"scope\":[\"meta.class entity.name.type.class.js\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"JavaScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.js\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"JavaScript Terminator\",\"scope\":[\"terminator.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Meta Punctuation Definition\",\"scope\":[\"meta.js punctuation.definition.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Entity Names in Code Documentations\",\"scope\":[\"entity.name.type.instance.jsdoc\",\"entity.name.type.instance.phpdoc\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"Other Variables in Code Documentations\",\"scope\":[\"variable.other.jsdoc\",\"variable.other.phpdoc\"],\"settings\":{\"foreground\":\"#3e697c\"}},{\"name\":\"JavaScript module imports and exports\",\"scope\":[\"variable.other.meta.import.js\",\"meta.import.js variable.other\",\"variable.other.meta.export.js\",\"meta.export.js variable.other\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Variable Parameter Function\",\"scope\":[\"variable.parameter.function.js\"],\"settings\":{\"foreground\":\"#555ea2\"}},{\"name\":\"JavaScript[React] Variable Other Object\",\"scope\":[\"variable.other.object.js\",\"variable.other.object.jsx\",\"variable.object.property.js\",\"variable.object.property.jsx\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Variables\",\"scope\":[\"variable.js\",\"variable.other.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JavaScript Entity Name Type\",\"scope\":[\"entity.name.type.js\",\"entity.name.type.module.js\"],\"settings\":{\"foreground\":\"#111111\",\"fontStyle\":\"\"}},{\"name\":\"JavaScript Support Classes\",\"scope\":[\"support.class.js\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"JSON Property Names\",\"scope\":[\"support.type.property-name.json\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"JSON Support Constants\",\"scope\":[\"support.constant.json\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"JSON Property values (string)\",\"scope\":[\"meta.structure.dictionary.value.json string.quoted.double\"],\"settings\":{\"foreground\":\"#7c5686\"}},{\"name\":\"Strings in JSON values\",\"scope\":[\"string.quoted.double.json punctuation.definition.string.json\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Specific JSON Property values like null\",\"scope\":[\"meta.structure.dictionary.json meta.structure.dictionary.value constant.language\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"JavaScript Other Variable\",\"scope\":[\"variable.other.object.js\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Ruby Variables\",\"scope\":[\"variable.other.ruby\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Ruby Class\",\"scope\":[\"entity.name.type.class.ruby\"],\"settings\":{\"foreground\":\"#984e4d\"}},{\"name\":\"Ruby Hashkeys\",\"scope\":[\"constant.language.symbol.hashkey.ruby\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Ruby Symbols\",\"scope\":[\"constant.language.symbol.ruby\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"LESS Tag names\",\"scope\":[\"entity.name.tag.less\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"LESS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.css\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Attribute Name for LESS\",\"scope\":[\"meta.attribute-selector.less entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Markdown Headings\",\"scope\":[\"markup.heading.markdown\",\"markup.heading.setext.1.markdown\",\"markup.heading.setext.2.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown Italics\",\"scope\":[\"markup.italic.markdown\"],\"settings\":{\"foreground\":\"#8844ae\",\"fontStyle\":\"italic\"}},{\"name\":\"Markdown Bold\",\"scope\":[\"markup.bold.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\",\"fontStyle\":\"bold\"}},{\"name\":\"Markdown Quote + others\",\"scope\":[\"markup.quote.markdown\"],\"settings\":{\"foreground\":\"#5c6285\"}},{\"name\":\"Markdown Raw Code + others\",\"scope\":[\"markup.inline.raw.markdown\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Markdown Links\",\"scope\":[\"markup.underline.link.markdown\",\"markup.underline.link.image.markdown\"],\"settings\":{\"foreground\":\"#954f5a\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Link Title and Description\",\"scope\":[\"string.other.link.title.markdown\",\"string.other.link.description.markdown\"],\"settings\":{\"foreground\":\"#403f53\",\"fontStyle\":\"underline\"}},{\"name\":\"Markdown Punctuation\",\"scope\":[\"punctuation.definition.string.markdown\",\"punctuation.definition.string.begin.markdown\",\"punctuation.definition.string.end.markdown\",\"meta.link.inline.markdown punctuation.definition.string\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown MetaData Punctuation\",\"scope\":[\"punctuation.definition.metadata.markdown\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Markdown List Punctuation\",\"scope\":[\"beginning.punctuation.definition.list.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Markdown Inline Raw String\",\"scope\":[\"markup.inline.raw.string.markdown\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"PHP Variables\",\"scope\":[\"variable.other.php\",\"variable.other.property.php\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Support Classes in PHP\",\"scope\":[\"support.class.php\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Punctuations in PHP function calls\",\"scope\":[\"meta.function-call.php punctuation\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"PHP Global Variables\",\"scope\":[\"variable.other.global.php\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Declaration Punctuation in PHP Global Variables\",\"scope\":[\"variable.other.global.php punctuation.definition.variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Language Constants in Python\",\"scope\":[\"constant.language.python\"],\"settings\":{\"foreground\":\"#a24848\"}},{\"name\":\"Python Function Parameter and Arguments\",\"scope\":[\"variable.parameter.function.python\",\"meta.function-call.arguments.python\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Python Function Call\",\"scope\":[\"meta.function-call.python\",\"meta.function-call.generic.python\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"Punctuations in Python\",\"scope\":[\"punctuation.python\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Decorator Functions in Python\",\"scope\":[\"entity.name.function.decorator.python\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Python Language Variable\",\"scope\":[\"source.python variable.language.special\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Python import control keyword\",\"scope\":[\"keyword.control\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"SCSS Variable\",\"scope\":[\"variable.scss\",\"variable.sass\",\"variable.parameter.url.scss\",\"variable.parameter.url.sass\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"Variables in SASS At-Rules\",\"scope\":[\"source.css.scss meta.at-rule variable\",\"source.css.sass meta.at-rule variable\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"Attribute Name for SASS\",\"scope\":[\"meta.attribute-selector.scss entity.other.attribute-name.attribute\",\"meta.attribute-selector.sass entity.other.attribute-name.attribute\"],\"settings\":{\"foreground\":\"#aa0982\"}},{\"name\":\"Tag names in SASS\",\"scope\":[\"entity.name.tag.scss\",\"entity.name.tag.sass\"],\"settings\":{\"foreground\":\"#096e72\"}},{\"name\":\"SASS Keyword Other Unit\",\"scope\":[\"keyword.other.unit.scss\",\"keyword.other.unit.sass\"],\"settings\":{\"foreground\":\"#8844ae\"}},{\"name\":\"TypeScript[React] Variables and Object Properties\",\"scope\":[\"variable.other.readwrite.alias.ts\",\"variable.other.readwrite.alias.tsx\",\"variable.other.readwrite.ts\",\"variable.other.readwrite.tsx\",\"variable.other.object.ts\",\"variable.other.object.tsx\",\"variable.object.property.ts\",\"variable.object.property.tsx\",\"variable.other.ts\",\"variable.other.tsx\",\"variable.tsx\",\"variable.ts\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript[React] Entity Name Types\",\"scope\":[\"entity.name.type.ts\",\"entity.name.type.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript[React] Node Classes\",\"scope\":[\"support.class.node.ts\",\"support.class.node.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"TypeScript[React] Entity Name Types as Parameters\",\"scope\":[\"meta.type.parameters.ts entity.name.type\",\"meta.type.parameters.tsx entity.name.type\"],\"settings\":{\"foreground\":\"#4d667b\"}},{\"name\":\"TypeScript[React] Import/Export Punctuations\",\"scope\":[\"meta.import.ts punctuation.definition.block\",\"meta.import.tsx punctuation.definition.block\",\"meta.export.ts punctuation.definition.block\",\"meta.export.tsx punctuation.definition.block\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.decorator punctuation.decorator.ts\",\"meta.decorator punctuation.decorator.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"TypeScript[React] Punctuation Decorators\",\"scope\":[\"meta.tag.js meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"YAML Entity Name Tags\",\"scope\":[\"entity.name.tag.yaml\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"JavaScript Variable Other ReadWrite\",\"scope\":[\"variable.other.readwrite.js\",\"variable.parameter\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"Support Class Component\",\"scope\":[\"support.class.component.js\",\"support.class.component.tsx\"],\"settings\":{\"foreground\":\"#aa0982\",\"fontStyle\":\"\"}},{\"name\":\"Text nested in React tags\",\"scope\":[\"meta.jsx.children\",\"meta.jsx.children.js\",\"meta.jsx.children.tsx\"],\"settings\":{\"foreground\":\"#403f53\"}},{\"name\":\"TypeScript Classes\",\"scope\":[\"meta.class entity.name.type.class.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript Entity Name Type\",\"scope\":[\"entity.name.type.tsx\",\"entity.name.type.module.tsx\"],\"settings\":{\"foreground\":\"#111111\"}},{\"name\":\"TypeScript Class Variable Keyword\",\"scope\":[\"meta.class.ts meta.var.expr.ts storage.type.ts\",\"meta.class.tsx meta.var.expr.tsx storage.type.tsx\"],\"settings\":{\"foreground\":\"#76578b\"}},{\"name\":\"TypeScript Method Declaration e.g. `constructor`\",\"scope\":[\"meta.method.declaration storage.type.ts\",\"meta.method.declaration storage.type.tsx\"],\"settings\":{\"foreground\":\"#3b61b0\"}},{\"name\":\"normalize font style of certain components\",\"scope\":[\"meta.property-list.css meta.property-value.css variable.other.less\",\"meta.property-list.scss variable.scss\",\"meta.property-list.sass variable.sass\",\"meta.brace\",\"keyword.operator.operator\",\"keyword.operator.or.regexp\",\"keyword.operator.expression.in\",\"keyword.operator.relational\",\"keyword.operator.assignment\",\"keyword.operator.comparison\",\"keyword.operator.type\",\"keyword.operator\",\"keyword\",\"punctuation.definition.string\",\"punctuation\",\"variable.other.readwrite.js\",\"storage.type\",\"source.css\",\"string.quoted\"],\"settings\":{\"fontStyle\":\"\"}}],\"styleOverrides\":{\"frames\":{\"editorBackground\":\"var(--sl-color-gray-7)\",\"terminalBackground\":\"var(--sl-color-gray-7)\",\"editorActiveTabBackground\":\"var(--sl-color-gray-7)\",\"terminalTitlebarDotsForeground\":\"color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)\",\"terminalTitlebarDotsOpacity\":\"0.75\",\"inlineButtonForeground\":\"var(--sl-color-text)\",\"frameBoxShadowCssValue\":\"none\"},\"textMarkers\":{\"markBackground\":\"#0000001a\",\"markBorderColor\":\"#00000055\"}}}],\"defaultLocale\":\"en\",\"cascadeLayer\":\"starlight.components\",\"styleOverrides\":{\"borderRadius\":\"0px\",\"borderWidth\":\"1px\",\"codePaddingBlock\":\"0.75rem\",\"codePaddingInline\":\"1rem\",\"codeFontFamily\":\"var(--__sl-font-mono)\",\"codeFontSize\":\"var(--sl-text-code)\",\"codeLineHeight\":\"var(--sl-line-height)\",\"uiFontFamily\":\"var(--__sl-font)\",\"textMarkers\":{\"lineDiffIndicatorMarginLeft\":\"0.25rem\",\"defaultChroma\":\"45\",\"backgroundOpacity\":\"60%\"}},\"plugins\":[{\"name\":\"Starlight Plugin\",\"hooks\":{}},{\"name\":\"astro-expressive-code\",\"hooks\":{}}]}]],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"prefetch\":{\"prefetchAll\":true},\"i18n\":{\"defaultLocale\":\"en\",\"locales\":[\"en\"],\"routing\":{\"prefixDefaultLocale\":false,\"redirectToDefaultLocale\":false,\"fallbackType\":\"redirect\"}}}","docs",["Map",11,12,27,28,42,43],"index",{"id":11,"data":13,"filePath":25,"digest":26,"deferredRender":16},{"title":14,"description":15,"editUrl":16,"head":17,"template":18,"hero":19,"sidebar":22,"pagefind":16,"draft":23},"Starter Tutorial","Get started building a tutorial.",true,[],"splash",{"tagline":20,"actions":21},"Starting a tutorial!",[],{"hidden":23,"attrs":24},false,{},"src/content/docs/index.mdx","369e120ef938088e","reference",{"id":27,"data":29,"body":39,"filePath":40,"digest":41,"deferredRender":16},{"title":30,"description":31,"editUrl":16,"head":32,"template":33,"hero":34,"sidebar":37,"pagefind":16,"draft":23},"Reference","Get started building your docs site with Starlight.",[],"doc",{"tagline":35,"actions":36},"Components that can be used in Tutorials",[],{"hidden":23,"attrs":38},{},"## Asides\n\n{% aside %}\nNote aside.\n{% /aside %}\n\n```\n{% aside %}\nNote aside.\n{% /aside %}\n```\n\n{% aside type=\"caution\" %}\nCaution aside.\n{% /aside %}\n\n```\n{% aside type=\"caution\" %}\nCaution aside.\n{% /aside %}\n```\n\n{% aside type=\"tip\" %}\nTip aside.\n{% /aside %}\n\n```\n{% aside type=\"tip\" %}\nTip aside.\n{% /aside %}\n```\n\n{% aside type=\"danger\" %}\nDanger aside.\n{% /aside %}\n\n```\n{% aside type=\"danger\" %}\nDanger aside.\n{% /aside %}\n```\n\n## Blockquotes\n\n> This is a blockquote.\n>\n> This is another line in the blockquote.\n\n## Code Blocks\n\n```js\nfunction demo() {\n // This is a comment\n console.log('Hello World');\n}\n```\n\n## File Trees\n\n{% filetree %}\n\n- astro.config.mjs\n- package.json\n- src\n - components\n - Header.astro\n - Title.astro\n - pages/\n\n{% /filetree %}\n\n```\n{% filetree %}\n\n- astro.config.mjs\n- package.json\n- src\n - components\n - Header.astro\n - Title.astro\n - pages/\n\n{% /filetree %}\n```\n\n## Steps\n\n{% steps %}\n\n1. Import the component into your MDX file:\n\n ```js\n import { Steps } from '@astrojs/starlight/components';\n ```\n\n2. Wrap `\u003CSteps>` around your ordered list items.\n\n{% /steps %}\n\n```\n {% steps %}\n\n 1. Import the component into your MDX file:\n\n ```js\n import { Steps } from '@astrojs/starlight/components';\n ```\n\n 2. Wrap `\u003CSteps>` around your ordered list items.\n\n {% /steps %}\n```\n\n## Tabs\n\n{% tabs %}\n{% tabitem label=\"Tab 1\" %}\nTab 1 text\n{% /tabitem %}\n\n{% tabitem label=\"Tab 2\" %}\nTab 2 text\n{% /tabitem %}\n{% /tabs %}\n\n```\n{% tabs %}\n{% tabitem label=\"Tab 1\" %}\nTab 1 text\n{% /tabitem %}\n\n{% tabitem label=\"Tab 2\" %}\nTab 2 text\n{% /tabitem %}\n{% /tabs %}\n```","src/content/docs/reference.mdoc","17bf736636d92cae","01_first_step",{"id":42,"data":44,"body":50,"filePath":51,"digest":52,"rendered":53},{"title":45,"description":46,"editUrl":16,"head":47,"template":33,"sidebar":48,"pagefind":16,"draft":23},"Introduction","An introduction to the onboarding tutorial",[],{"hidden":23,"attrs":49},{},"# Starter Tutorial\n\nThis is a paragraph about what the tutorial contains.\n\nHere is some code:\n\n```js\nfunction demo() {\n // This is a comment\n console.log('Hello World');\n}\n```","src/content/docs/01_First_Step.md","5f8f5e057d466cd2",{"html":54,"metadata":55},"\u003Cdiv class=\"sl-heading-wrapper level-h1\">\u003Ch1 id=\"starter-tutorial\">Starter Tutorial\u003C/h1>\u003Ca class=\"sl-anchor-link\" href=\"#starter-tutorial\">\u003Cspan aria-hidden=\"true\" class=\"sl-anchor-icon\">\u003Csvg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\">\u003Cpath fill=\"currentcolor\" d=\"m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z\">\u003C/path>\u003C/svg>\u003C/span>\u003Cspan class=\"sr-only\" data-pagefind-ignore=\"\">Section titled “Starter Tutorial”\u003C/span>\u003C/a>\u003C/div>\n\u003Cp>This is a paragraph about what the tutorial contains.\u003C/p>\n\u003Cp>Here is some code:\u003C/p>\n\u003Cdiv class=\"expressive-code\">\u003Clink rel=\"stylesheet\" href=\"/_astro/ec.v4551.css\">\u003Cscript type=\"module\" src=\"/_astro/ec.0vx5m.js\">\u003C/script>\u003Cfigure class=\"frame not-content\">\u003Cfigcaption class=\"header\">\u003C/figcaption>\u003Cpre data-language=\"js\">\u003Ccode>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan style=\"--0:#C792EA;--1:#8844AE\">function\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\"> \u003C/span>\u003Cspan style=\"--0:#82AAFF;--1:#3B61B0\">demo\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">()\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\"> {\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan class=\"indent\"> \u003C/span>\u003Cspan style=\"--0:#919F9F;--1:#5D6376\">// This is a comment\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan class=\"indent\"> \u003C/span>\u003Cspan style=\"--0:#7FDBCA;--1:#096E72\">console\u003C/span>\u003Cspan style=\"--0:#C792EA;--1:#8844AE\">.\u003C/span>\u003Cspan style=\"--0:#82AAFF;--1:#3B61B0\">log\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">(\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">'\u003C/span>\u003Cspan style=\"--0:#ECC48D;--1:#984E4D\">Hello World\u003C/span>\u003Cspan style=\"--0:#D9F5DD;--1:#111111\">'\u003C/span>\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">);\u003C/span>\u003C/div>\u003C/div>\u003Cdiv class=\"ec-line\">\u003Cdiv class=\"code\">\u003Cspan style=\"--0:#D6DEEB;--1:#403F53\">}\u003C/span>\u003C/div>\u003C/div>\u003C/code>\u003C/pre>\u003Cdiv class=\"copy\">\u003Cdiv aria-live=\"polite\">\u003C/div>\u003Cbutton title=\"Copy to clipboard\" data-copied=\"Copied!\" data-code=\"function demo() { // This is a comment console.log('Hello World');}\">\u003Cdiv>\u003C/div>\u003C/button>\u003C/div>\u003C/figure>\u003C/div>",{"headings":56,"localImagePaths":60,"remoteImagePaths":61,"frontmatter":62,"imagePaths":63},[57],{"depth":58,"slug":59,"text":14},1,"starter-tutorial",[],[],{"title":45,"description":46},[]] \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/.astro/settings.json b/tutorials/vonage_video_ios_app-theme-customization/.astro/settings.json new file mode 100644 index 00000000..6945ae7d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1785852561838 + } +} \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/.astro/types.d.ts b/tutorials/vonage_video_ios_app-theme-customization/.astro/types.d.ts new file mode 100644 index 00000000..03d7cc43 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/.astro/types.d.ts @@ -0,0 +1,2 @@ +/// +/// \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/README.md b/tutorials/vonage_video_ios_app-theme-customization/README.md new file mode 100644 index 00000000..31bb373e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/README.md @@ -0,0 +1,21 @@ + +# vonage_video_ios_app-theme-customization +Generated Tutorial Environment. + +## Start Learning +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Vonage-Community/tutorial-interactive_tutorials?devcontainer_path=.devcontainer/vonage_video_ios_app-theme-customization/devcontainer.json) + +## 🛠️ Edit this Tutorial +Want to make changes to the lesson, add steps, or update starter files? +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/Vonage-Community/tutorial-interactive_tutorials/tree/main/sources/vonage_video_ios_app-theme-customization) +1. Clone this repo: `git clone https://github.com/Vonage-Community/tutorial-interactive_tutorials.git` +2. Open the `sources/vonage_video_ios_app-theme-customization` folder in VS Code +3. Install dependencies and start the dev server: `npm install && npm run dev` +4. Preview at `http://localhost:4321` — open it in VS Code via **Ctrl+Shift+P** → **Simple Browser: Show** + +### Environment Details +- **Tutorial Steps**: Port 1234 +- **Your Workspace**: `tutorials/vonage_video_ios_app-theme-customization` +- **Project Code**: `tutorials/vonage_video_ios_app-theme-customization/project` +- **Tutorial Source**: `sources/vonage_video_ios_app-theme-customization` + \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/astro.config.mjs b/tutorials/vonage_video_ios_app-theme-customization/astro.config.mjs new file mode 100644 index 00000000..8f4ad034 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import relativeLinks from 'astro-relative-links'; +import vonageIntegration from './vonage-toolbar/integration.ts'; + +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + relativeLinks(), + vonageIntegration, + starlight({ + title: 'Vonage Coding Exercise', + tableOfContents: true, + pagefind: false, + }), + markdoc(), + ], +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/download-toolbar.js b/tutorials/vonage_video_ios_app-theme-customization/download-toolbar.js new file mode 100644 index 00000000..34c88a09 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/download-toolbar.js @@ -0,0 +1,40 @@ +import fs from 'fs/promises'; + +async function downloadFile(url, filename) { + console.log('downloading: ', filename); + try { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + + await fs.mkdir('./vonage-toolbar', { recursive: true }); + + await fs.writeFile( + `./vonage-toolbar/${filename}`, + Buffer.from(buffer) + ); + + console.log(`✅ File ${filename} downloaded and written successfully!`); + + } catch (error) { + console.error(`❌ Error downloading/writing ${filename}:`, error); + } +} + +async function main() { + console.log('download toolbar application files'); + + await Promise.all([ + downloadFile( + 'https://raw.githubusercontent.com/vonage-community/tutorial-interactive_tutorials/refs/heads/main/toolbar-app/vonage-toolbar/integration.ts', + 'integration.ts' + ), + downloadFile( + 'https://raw.githubusercontent.com/vonage-community/tutorial-interactive_tutorials/refs/heads/main/toolbar-app/vonage-toolbar/app.ts', + 'app.ts' + ) + ]); + + console.log('🎉 All downloads complete. Starting Astro...'); +} + +main(); diff --git a/tutorials/vonage_video_ios_app-theme-customization/markdoc.config.mjs b/tutorials/vonage_video_ios_app-theme-customization/markdoc.config.mjs new file mode 100644 index 00000000..329155ae --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/markdoc.config.mjs @@ -0,0 +1,6 @@ +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import starlightMarkdoc from '@astrojs/starlight-markdoc'; + +export default defineMarkdocConfig({ + extends: [starlightMarkdoc()], +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/package-lock.json b/tutorials/vonage_video_ios_app-theme-customization/package-lock.json new file mode 100644 index 00000000..c22aa404 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/package-lock.json @@ -0,0 +1,8488 @@ +{ + "name": "@example/starlight-basics", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@example/starlight-basics", + "version": "0.0.1", + "hasInstallScript": true, + "dependencies": { + "@astrojs/markdoc": "^0.15.10", + "@astrojs/starlight": "^0.37.6", + "@astrojs/starlight-markdoc": "^0.5.1", + "adm-zip": "^0.5.16", + "astro": "^5.18.1", + "astro-relative-links": "^0.4.2", + "sharp": "^0.33.0" + }, + "devDependencies": { + "http-server": "^14.1.1" + } + }, + "node_modules/@astrojs/compiler": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", + "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", + "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "license": "MIT" + }, + "node_modules/@astrojs/markdoc": { + "version": "0.15.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdoc/-/markdoc-0.15.10.tgz", + "integrity": "sha512-05V4Vy4J14aDpBstKbuyO7ArY+VCF80SElWyx80ddeWkUiOERw4SjGbnzgAI4AQtDdJ+pJdlv/16TMM65+YafA==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/markdown-remark": "6.3.10", + "@astrojs/prism": "3.3.0", + "@markdoc/markdoc": "^0.5.4", + "esbuild": "^0.25.0", + "github-slugger": "^2.0.0", + "htmlparser2": "^10.0.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^5.0.0" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", + "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.5", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.19.0", + "smol-toml": "^1.5.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.13.tgz", + "integrity": "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "6.3.10", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.15.0", + "es-module-lexer": "^1.7.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + }, + "peerDependencies": { + "astro": "^5.0.0" + } + }, + "node_modules/@astrojs/prism": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.0.tgz", + "integrity": "sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==", + "license": "MIT", + "dependencies": { + "sitemap": "^8.0.2", + "stream-replace-string": "^2.0.0", + "zod": "^3.25.76" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.37.6.tgz", + "integrity": "sha512-wQrKwH431q+8FsLBnNQeG+R36TMtEGxTQ2AuiVpcx9APcazvL3n7wVW8mMmYyxX0POjTnxlcWPkdMGR3Yj1L+w==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-remark": "^6.3.1", + "@astrojs/mdx": "^4.2.3", + "@astrojs/sitemap": "^3.3.0", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.41.1", + "bcp-47": "^2.1.0", + "hast-util-from-html": "^2.0.1", + "hast-util-select": "^6.0.2", + "hast-util-to-string": "^3.0.0", + "hastscript": "^9.0.0", + "i18next": "^23.11.5", + "js-yaml": "^4.1.0", + "klona": "^2.0.6", + "magic-string": "^0.30.17", + "mdast-util-directive": "^3.0.0", + "mdast-util-to-markdown": "^2.1.0", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.3.0", + "rehype": "^13.0.1", + "rehype-format": "^5.0.0", + "remark-directive": "^3.0.0", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.2" + }, + "peerDependencies": { + "astro": "^5.5.0" + } + }, + "node_modules/@astrojs/starlight-markdoc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@astrojs/starlight-markdoc/-/starlight-markdoc-0.5.1.tgz", + "integrity": "sha512-YxTvCHr/0ovDUhuRm0xeCjjGYtnqpa9EtTwc9fTbRO7Zpnsu1mDmm2L9HHSBWZ4YsFb005/GJYNDjk4WXNLoUg==", + "license": "MIT", + "peerDependencies": { + "@astrojs/markdoc": ">=0.12.1", + "@astrojs/starlight": ">=0.35.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.2.0", + "debug": "^4.4.0", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "is-docker": "^3.0.0", + "is-wsl": "^3.1.0", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", + "integrity": "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.41.7.tgz", + "integrity": "sha512-ck92uZYZ9Wba2zxkiZLsZGi9N54pMSAVdrI9uW3Oo9AtLglD5RmrdTwbYPCT2S/jC36JGB2i+pnQtBm/Ib2+dg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.41.7.tgz", + "integrity": "sha512-diKtxjQw/979cTglRFaMCY/sR6hWF0kSMg8jsKLXaZBSfGS0I/Hoe7Qds3vVEgeoW+GHHQzMcwvgx/MOIXhrTA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.41.7.tgz", + "integrity": "sha512-DL605bLrUOgqTdZ0Ot5MlTaWzppRkzzqzeGEu7ODnHF39IkEBbFdsC7pbl3LbUQ1DFtnfx6rD54k/cdofbW6KQ==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7", + "shiki": "^3.2.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.41.7.tgz", + "integrity": "sha512-Ewpwuc5t6eFdZmWlFyeuy3e1PTQC0jFvw2Q+2bpcWXbOZhPLsT7+h8lsSIJxb5mS7wZko7cKyQ2RLYDyK6Fpmw==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@markdoc/markdoc": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@markdoc/markdoc/-/markdoc-0.5.4.tgz", + "integrity": "sha512-36YFNlqFk//gVNGm5xZaTWVwbAVF2AOmVjf1tiUrS6tCoD/YSkVy2E3CkAfhc5MlKcjparL/QFHCopxL4zRyaQ==", + "license": "MIT", + "engines": { + "node": ">=14.7.0" + }, + "optionalDependencies": { + "@types/linkify-it": "^3.0.1", + "@types/markdown-it": "12.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.3.0.tgz", + "integrity": "sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.3.0.tgz", + "integrity": "sha512-zlGHA23uuXmS8z3XxEGmbHpWDxXfPZ47QS06tGUq0HDcZjXjXHeLG+cboOy828QIV5FXsm9MjfkP5e4ZNbOkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.3.0.tgz", + "integrity": "sha512-CGKT9ccd3+oRK6STXGgfH+m0DbOKayX6QGlq38TfE1ZfUcPc5+ulTuzDbZUnMo+bubsEOIypm4Pl2iEyzZ1cNg==", + "license": "MIT" + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.3.0.tgz", + "integrity": "sha512-8lsxNAiBRUk72JvetSBXs4WRpYrQrVJXjlRRnOL6UCdBN9Nlsz0t7hWstRk36+JqHpGWOKYiuHLzGYqYAqoOnQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.3.0.tgz", + "integrity": "sha512-hAvqdPJv7A20Ucb6FQGE6jhjqy+vZ6pf+s2tFMNtMBG+fzcdc91uTw7aP/1Vo5plD0dAOHwdxfkyw0ugal4kcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.3.0.tgz", + "integrity": "sha512-BR1bIRWOMqkf8IoU576YDhij1Wd/Zf2kX/kCI0b2qzCKC8wcc2GQJaaRMCpzvCCrmliO4vtJ6RITp/AnoYUUmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.1.tgz", + "integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.0", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", + "@astrojs/telemetry": "3.3.0", + "@capsizecss/unpack": "^4.0.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "acorn": "^8.15.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "boxen": "8.0.1", + "ci-info": "^4.3.1", + "clsx": "^2.1.1", + "common-ancestor-path": "^1.0.1", + "cookie": "^1.1.1", + "cssesc": "^3.0.0", + "debug": "^4.4.3", + "deterministic-object-hash": "^2.0.2", + "devalue": "^5.6.2", + "diff": "^8.0.3", + "dlv": "^1.1.3", + "dset": "^3.1.4", + "es-module-lexer": "^1.7.0", + "esbuild": "^0.27.3", + "estree-walker": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.0", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.1", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "p-limit": "^6.2.0", + "p-queue": "^8.1.1", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.3", + "prompts": "^2.4.2", + "rehype": "^13.0.2", + "semver": "^7.7.3", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "svgo": "^4.0.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tsconfck": "^3.1.6", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.3", + "unist-util-visit": "^5.0.0", + "unstorage": "^1.17.4", + "vfile": "^6.0.3", + "vite": "^6.4.1", + "vitefu": "^1.1.1", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^21.1.1", + "yocto-spinner": "^0.2.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.1", + "zod-to-ts": "^1.2.0" + }, + "bin": { + "astro": "astro.js" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro-expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.41.7.tgz", + "integrity": "sha512-hUpogGc6DdAd+I7pPXsctyYPRBJDK7Q7d06s4cyP0Vz3OcbziP3FNzN0jZci1BpCvLn9675DvS7B9ctKKX64JQ==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.41.7" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" + } + }, + "node_modules/astro-relative-links": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/astro-relative-links/-/astro-relative-links-0.4.2.tgz", + "integrity": "sha512-YXwnmZz47yxiIIxWR10LwpGlqvGTERJ7JWX2lFRINTg/sFaPkqTx9xNM50W1+9FNMMxtTn7C8YNW5yDVX33Vzw==", + "license": "MIT", + "dependencies": { + "glob": "^10.3.10" + }, + "peerDependencies": { + "astro": ">=3.0.0" + } + }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", + "license": "MIT" + }, + "node_modules/astro/node_modules/@astrojs/markdown-remark": { + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/prism": "3.3.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "import-meta-resolve": "^4.2.0", + "js-yaml": "^4.1.1", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/astro/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/astro/node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/astro/node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/astro/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/astro/node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bcp-47": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz", + "integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", + "license": "ISC" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/deterministic-object-hash": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "license": "MIT", + "dependencies": { + "base-64": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.7.tgz", + "integrity": "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.41.7", + "@expressive-code/plugin-frames": "^0.41.7", + "@expressive-code/plugin-shiki": "^0.41.7", + "@expressive-code/plugin-text-markers": "^0.41.7" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.2.tgz", + "integrity": "sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/h3": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", + "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.2", + "crossws": "^0.3.5", + "defu": "^6.1.4", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.2.tgz", + "integrity": "sha512-SfMzfdAi/zAoZ1KkFEyyeXBn7u/ShQrfd675ZEE9M3qj+PMFX05xubzRyF76CCSJu8au9jgVxDV1+okFvgZU4A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^6.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.0.tgz", + "integrity": "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/http-server/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz", + "integrity": "sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", + "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.0.tgz", + "integrity": "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz", + "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", + "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-limit": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", + "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.3.0.tgz", + "integrity": "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.3.0", + "@pagefind/darwin-x64": "1.3.0", + "@pagefind/linux-arm64": "1.3.0", + "@pagefind/linux-x64": "1.3.0", + "@pagefind/windows-x64": "1.3.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-expressive-code": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.7.tgz", + "integrity": "sha512-25f8ZMSF1d9CMscX7Cft0TSQIqdwjce2gDOvQ+d/w0FovsMwrSt3ODP4P3Z7wO1jsIJ4eYyaDRnIR/27bd/EMQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.41.7" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", + "integrity": "sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz", + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.3.tgz", + "integrity": "sha512-9Ew1tR2WYw8RGE2XLy7GjkusvYXy8Rg6y8TYuBuQMfIEdGcWoJpY2Wr5DzsEiL/TKCw56+YKTCCUHglorEYK+A==", + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.31.0.tgz", + "integrity": "sha512-yCxltHW07Nkhv/1F6wWBr8kz+5BGMfP+RbRSYFnegVb0qV/UMT0G0ElBloPVerqn4M2ZV80Ir1FtCcYv1cT6vQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.6.0.tgz", + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", + "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", + "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.5", + "lru-cache": "^11.2.0", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zod-to-ts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "peerDependencies": { + "typescript": "^4.9.4 || ^5.0.2", + "zod": "^3" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/package.json b/tutorials/vonage_video_ios_app-theme-customization/package.json new file mode 100644 index 00000000..30533aa2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/package.json @@ -0,0 +1,27 @@ +{ + "name": "@example/starlight-basics", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "node download-toolbar.js && astro dev", + "start": "node download-toolbar.js && astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro", + "start:tutorial": "http-server steps -p 1234 --cors -c-1", + "postinstall": "cd project && yarn" + }, + "dependencies": { + "@astrojs/markdoc": "^0.15.10", + "@astrojs/starlight": "^0.37.6", + "@astrojs/starlight-markdoc": "^0.5.1", + "adm-zip": "^0.5.16", + "astro": "^5.18.1", + "astro-relative-links": "^0.4.2", + "sharp": "^0.33.0" + }, + "devDependencies": { + "http-server": "^14.1.1" + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.clinerules b/tutorials/vonage_video_ios_app-theme-customization/project/.clinerules new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.clinerules @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.cursorrules b/tutorials/vonage_video_ios_app-theme-customization/project/.cursorrules new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.cursorrules @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.eslintignore b/tutorials/vonage_video_ios_app-theme-customization/project/.eslintignore new file mode 100644 index 00000000..4769bc38 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.eslintignore @@ -0,0 +1,15 @@ +# some individual files +.eslintrc.js +*.json +*.md +*.config.ts +*.config.js + +# some directories +*/dist/ +*/node_modules/ +public/js/ +frontend/docs/ + +# dependency generator +scripts/* diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.eslintrc-filenames b/tutorials/vonage_video_ios_app-theme-customization/project/.eslintrc-filenames new file mode 100644 index 00000000..fe0fe201 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.eslintrc-filenames @@ -0,0 +1,14 @@ +{ + "plugins": [ + "filename-rules" + ], + "rules": { + "filename-rules/match": [ + 2, + { + ".ts": "camelCase", + ".tsx": "camelCase" + } + ] + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/1-bug-report.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 00000000..1d680e03 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,72 @@ +name: Bug Report +description: File a bug report. +title: '[Bug]: ' +labels: ['bug', 'triage'] +body: + - type: checkboxes + id: known-issue + attributes: + label: Is this a known issue? + description: Please search through our [Known Issues](../../docs/KNOWN_ISSUES.md) to see if this bug has been reported. + options: + - label: I have searched the existing issues + required: true + + - type: textarea + id: description + attributes: + label: Describe the bug + description: Please include relevant screenshots + value: 'A wild bug appeared!' + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Describe the expected behavior + value: 'I would expect...' + validations: + required: true + + - type: textarea + id: to-reproduce + attributes: + label: Include steps to reproduce + value: 'Steps to reproduce:' + validations: + required: true + + - type: dropdown + id: platforms + attributes: + label: What platforms are you seeing the problem on? + multiple: true + options: + - Chrome + - Firefox + - Microsoft Edge + - Opera + - Safari + + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + + - type: input + id: session-id + attributes: + label: Session ID + placeholder: 1_abc123def456-fn4 + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](../../CODE_OF_CONDUCT.md). + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/2-feature-request.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 00000000..8539fa91 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: File a feature request. +title: '[Feature Request]: ' +labels: ['enhancement', 'triage'] +body: + - type: checkboxes + id: known-request + attributes: + label: Is this a known issue? + description: Please search through our [Known Issues](../../docs/KNOWN_ISSUES.md) to see if this feature has been requested. + options: + - label: I have searched the existing issues + required: true + + - type: textarea + id: description + attributes: + label: Describe the feature request + description: Please include relevant mockup images + value: 'I would like to see...' + validations: + required: true + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](../../CODE_OF_CONDUCT.md). + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/config.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d6085307 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Need help with running Vonage Video API React Application? + url: https://api.support.vonage.com/hc/en-us + about: Check out our support page. diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/deploy-vcr/action.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/deploy-vcr/action.yml new file mode 100644 index 00000000..fa83fb69 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/deploy-vcr/action.yml @@ -0,0 +1,57 @@ +name: Deploy to VCR +description: Deploys the application to Vonage Cloud Runtime + +inputs: + app-id: + description: VCR application ID + required: true + api-key: + description: VCR API key + required: true + api-secret: + description: VCR API secret + required: true + filename: + description: VCR config filename to deploy with + required: true + +outputs: + instance-url: + description: The deployed instance URL (if available) + value: ${{ steps.deploy.outputs.instance_url }} + +runs: + using: composite + steps: + - name: Deploy to VCR + id: deploy + shell: bash + env: + APP_ID: ${{ inputs.app-id }} + VCR_API_KEY: ${{ inputs.api-key }} + VCR_API_SECRET: ${{ inputs.api-secret }} + VCR_FILENAME: ${{ inputs.filename }} + run: | + cd backend/dist + + vcr deploy \ + --filename "$VCR_FILENAME" \ + --app-id "$APP_ID" \ + --api-key "$VCR_API_KEY" \ + --api-secret "$VCR_API_SECRET" \ + --region aws.euw1 \ + --graphql-endpoint https://graphql.euw1.runtime.vonage.cloud/v1/graphql \ + --timeout=15m \ + 2>&1 | tee deploy-vcr-logs.log + + if ! grep -Fxq '| Instance has been deployed!' deploy-vcr-logs.log ; then + echo "Deployment failed" + exit 1 + fi + + INSTANCE_URL=$(grep -oE 'https://[^ ]+' deploy-vcr-logs.log | tail -1 | sed -r 's/\x1B\[[0-9;]*[mK]//g') + if [ -z "$INSTANCE_URL" ]; then + echo "Failed to extract instance URL from deploy logs" + exit 1 + fi + echo "instance_url=${INSTANCE_URL}" >> "$GITHUB_OUTPUT" diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/install-vcr-cli/action.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/install-vcr-cli/action.yml new file mode 100644 index 00000000..265f41c5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/install-vcr-cli/action.yml @@ -0,0 +1,13 @@ +name: Install VCR CLI +description: Downloads and installs the Vonage Cloud Runtime CLI + +runs: + using: composite + steps: + - name: Install VCR CLI + shell: bash + run: | + set -euo pipefail + curl --proto =https --max-redirs 0 -fsSL https://raw.githubusercontent.com/Vonage/cloud-runtime-cli/main/script/install.sh -o install.sh + sudo bash install.sh + vcr -v diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/slack-notify/action.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/slack-notify/action.yml new file mode 100644 index 00000000..875f532b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/actions/slack-notify/action.yml @@ -0,0 +1,33 @@ +name: Slack Notification +description: Sends a Slack notification to the CI channel + +inputs: + slack-bot-token: + description: Slack bot token for authentication + required: true + color: + description: Attachment color hex code (e.g. 28a745 for green, FF0000 for red, FFA500 for orange) + required: true + message: + description: The attachment text content (supports Slack markdown) + required: true + +runs: + using: composite + steps: + - name: Send Slack notification + uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e # v1.26.0 + with: + channel-id: '#video-solutions-ci-notifications' + payload: | + { + "text": "Vera Deployment GHA Job", + "attachments": [ + { + "color": ${{ toJson(inputs.color) }}, + "text": ${{ toJson(inputs.message) }} + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ inputs.slack-bot-token }} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/copilot-instructions.md b/tutorials/vonage_video_ios_app-theme-customization/project/.github/copilot-instructions.md new file mode 100644 index 00000000..d2522d97 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/copilot-instructions.md @@ -0,0 +1,1660 @@ + +# Instruction + +All rules in this document should be treated as strict coding and architectural requirements for this repository. +When generating or editing code, always choose the option that is consistent with these rules. +When reviewing code, Copilot must also evaluate changes according to these rules and suggest corrections whenever the submitted code violates any of them. + +# Repository Overview + +- **Rule:** Vera is a video call application built with Vonage Video API and is being transformed into a reusable component library for Vonage Video API React SDK users. +- **Rule:** Video components, hooks, and primitives must be extracted into `libs/ui` and `libs/core`. +- **Rule:** Backend-agnostic Vonage Video API handler logic must live in `libs/api`. +- **Rule:** New features must be designed with **reusability** in mind and must remain **agnostic of Vera** wherever possible. + +--- + +# Project Architecture & Libraries + +This is a mono repo containing: + +- Libs: `ui`, `core`, `common`, `api` +- Main projects: `frontend`, `backend` +- `integration-test` + +--- + +# Technology Stack + +- Frontend: React, TypeScript, MUI, react-router-dom, axios +- Backend: Node.js, TypeScript, Express +- Integration tests: Playwright, TypeScript +- libs/ui: React, TypeScript, MUI +- libs/core: React, TypeScript +- libs/common: React, TypeScript + +React version: `^19.2` +TypeScript version: `^5.8.3` + +--- + +# General Development Guidelines, Import Rules + +## Library and feature placement + +- **Rule:** If a component is completely stateless and generic, it must be placed in: + - `libs/ui` for visual components + - `libs/core` if it is faceless (non-visual logic). + - `libs/common` for helpers, utilities, and hooks that are agnostic of the project. +- **Rule:** If the code is **backend-oriented** and represents **agnostic Vonage Video API orchestration**, it must be placed in `libs/api`. +- **Rule:** Vera-specific business logic (roles, permissions, product policy/decisions) must stay in the app layer (`frontend`/`backend`). +- **Rule:** This is **especially enforced** for video-related components such as publishers, subscribers, sessions, `videoView`s, etc. +- **Rule:** Helpers, utilities, and hooks that are agnostic of the project must be placed in `libs/common`. +- **Rule:** Logic that is shared between different projects (**frontend**, **backend**, **libs**) should be proposed for `libs/common`. +- **Rule:** Do not add new dependencies. Use only the installed dependencies. +- **Rule:** Do not add new state management libraries. Use only existing tooling. +- **Rule:** Components must be kept small, focused, and composable. + +## Testing Guidelines + +- **Rule:** Avoid overtesting. Simple helpers should be covered by only a couple of high-value use cases. +- **Rule:** Prefer tests that validate real functionality and behavior over redundant input permutations. + +--- + +# Coding Style & Programming Patterns + +- **Rule:** Prefer early returns / fast-fail to reduce nesting. +- **Rule:** Prefer **linear code** whenever possible. +- **Rule:** Prefer IIFE (Immediately Invoked Function Expression) for computing values linearly instead of branching assignments. + +**Violation:** + +```tsx +// Bad +let url = ''; + +if (condition1) { + url = 'value1'; +} else { + if (condition2) { + url = 'value2'; + } else { + url = 'value3'; + } +} + +this.API_URL = url; +``` + +**Correct:** + +```tsx +// Good +const url = (() => { + if (condition1) return 'value1'; + if (condition2) return 'value2'; + return 'value3'; +})(); + +this.API_URL = url; +``` + +- **Rule:** Use named boolean expressions or named helper functions for complex boolean conditions. + +**Violation:** + +```tsx +// Bad +if (user.isAdmin && user.isActive && hasValidSubscription(user)) { + // ... +} +``` + +**Correct:** + +```tsx +// Good +const isUserEligible = + user.isAdmin && user.isActive && hasValidSubscription(user); + +if (isUserEligible) { + // ... +} +``` + +**Correct (extracted helper):** + +```tsx +// Good +if (isUserEligible(user)) { + // ... +} +``` + +**Correct (simple null check):** + +```tsx +// Good +if (isNil(data)) return; +``` + +- **Rule:** Abbreviated or shortened names are banned across the codebase, except `req` and `res` when working with Express `Request` and `Response`. +- **Rule:** Use fully descriptive names, even if they are longer. Minification handles bundle size. + +**Violation:** + +```tsx +// Bad: abbreviating "User Details" to "UsrDtls" +function fetchUsrDtls() { + // ... +} +``` + +**Correct:** + +```tsx +// Good: fully spelled out words +function fetchUserDetails() { + // ... +} +``` + +**Violation:** + +```tsx +// Bad: abbreviating "VideoClient" to "vc" +const vc = new VideoClient(); +``` + +**Correct:** + +```tsx +// Good: fully spelled out variable name +const videoClient = new VonageVideoClient(); +``` + +- **Rule:** Prefer linear `tryCatch` helpers instead of nested `try/catch`. +- **Rule:** Nested `try/catch` blocks are banned. + +**Violation:** + +```tsx +// Bad +const ValidateRequest = async (req) => { + const validator = new RequestValidator(); + + let error = null; + let data = null; + + // nested try/catch just make the code messy and hard to read + try { + data = parseRequest(req.body); + } catch (error) { + error = error; + } + + if(error || !data) { + validator.addError(error?.message || 'Unknown parsing error'); + } + + // this is okay + validator.assert(); +}; +``` + +**Correct:** + +```tsx +// Good +import tryCatch from '@common/execution/tryCatch'; + +const myFunction = async () => { + const { result: data, error } = await tryCatch(() => fetchData()); + + if (error) { + console.error('Error fetching data:', error); + return defaultValue; + }; + + return data; +}; +``` + +## Type modeling & correctness + +- **Rule:** Prefer `named parameters` for functions with 2+ parameters or parameters of the same type. + +**Violation:** + +```tsx +// Bad +function createUser(name: string, age: number, isAdmin: boolean) { + // ... +} + +// Call site is hard to read, what does true mean? +createUser('Alice', 30, true); +``` + +**Correct:** + +```tsx +// Good +function createUser(args: { name: string; age: number; isAdmin: boolean }) { + // ... +} + +// Call site is clear and readable +createUser({ name: 'Alice', age: 30, isAdmin: true }); +``` +- **Rule:** Prefer type conjunctions (unions/intersections) over optional properties. + +**Violation:** + +```tsx +// Bad +type PaymentMethod = { + methodType: 'card' | 'bankTransfer' | 'cash'; + + // card-specific + cardNumber?: string; + cardExpiryMonth?: number; + cardExpiryYear?: number; + + // bank-transfer-specific + IBAN?: string; + + // cash-specific + cashReceivedByEmployeeId?: string; +}; + +const charge = (paymentMethod: PaymentMethod): Promise; + +// everything is optional, there is not type safety +charge({ + methodType: 'card', +}); +``` + +**Correct:** + +```tsx +type MethodType = 'card' | 'paypal' | 'bank_transfer'; + +type BasePaymentMethod = { + methodType: M; +}; + +type CardPaymentMethod = BasePaymentMethod<'card'> & { + methodType: 'card'; + cardNumber: string; + cardExpiryMonth: number; + cardExpiryYear: number; +}; + +type PayPalPaymentMethod = BasePaymentMethod<'paypal'> & { + methodType: 'paypal'; + email: string; +}; + +type BankTransferPaymentMethod = BasePaymentMethod<'bank_transfer'> & { + methodType: 'bank_transfer'; + accountNumber: string; + routingNumber: string; +}; + +type PaymentMethod = CardPaymentMethod | PayPalPaymentMethod | BankTransferPaymentMethod; + +const charge = (paymentMethod: PaymentMethod): Promise; + +charge({ + methodType: 'card', +}); // Error: missing cardNumber, cardExpiryMonth, cardExpiryYear + +charge({ + methodType: 'card', + cardNumber: '1234567890123456', + cardExpiryMonth: 12, + cardExpiryYear: 2025, +}); // OK + +charge({ + methodType: 'paypal', + email: 'john@some.com', + routingNumber: '123456', +}); // Error: routingNumber is not valid for PayPal +``` + +- **Rule:** Always use `type` for type-only imports. + +**Violation:** + +```tsx +// Bad +import { User } from './types'; +``` + +**Correct:** + +```tsx +// Good +import type { User } from './types'; // this will not be included in the compiled JS +``` + +- **Rule:** Prefer `type` over `interface` for type definitions. + +**Violation:** + +```tsx +// Bad +interface User { + id: string; + name: string; +} +``` + +**Correct:** + +```tsx +// Good +type User = { + id: string; + name: string; +}; + +// Good: This is okay since function overloads are not supported in the same way with `type`. +// Using a union of function types is not equivalent and is usually less readable with worse IntelliSense/error messages. +interface Select { + (callback: (state: State) => Selected): Selected; + + (callback: (state: State) => Selected, transformer: (selected: Selected) => Result): Result; +} + +// Good: This is an actual interface intend to be implemented by classes. +interface ILogger { + log(message: string): void; + error(message: string): void; +} +``` + +- **Rule:** Ban string IDs... Always prefer to use branded types for identifiers. + +**Violation:** + +```tsx +// Bad +type User = { + id: string; // string ID is not type-safe + name: string; +}; +``` + +**Correct:** + +```tsx +declare const _brand: unique symbol; + +// Good +type UserId = string & { [__brand]: 'UserId' }; + +type User = { + id: UserId; // branded type ID is type-safe + name: string; +}; + +// Even Better +// This allows us to run-time validate ids +type UserId = `user:${string}` & { [__brand]: 'UserId' }; + +// Good +// If the brand is duplicated across different entities use symbols +declare const UserIdBrand: unique symbol; + +// Try to avoid duplicating prefixes and brands but if necessary use symbols +type UserId = `user:${string}` & { [__brand]: typeof UserIdBrand }; +``` +--- + +# Folder and File Structure + +We use a **domain-driven** folder structure. + +- **Rule:** Each feature/domain must live in its own folder and contain all related components, hooks, utils, types, etc. +- **Rule:** Generic "global" buckets like `components/`, `hooks/`, `utils/` at the top are discouraged as a catch-all for unrelated features. + +**Violation:** + +```plaintext +// Bad + +src/ + components/ + Header.tsx + Footer.tsx + UserProfile.tsx + hooks/ + useAuth.ts + useFetchData.ts + utils/ + formatDate.ts + calculateAge.ts +``` + +**Correct:** + +```plaintext +// Good + +src/ + components/ + Banner/ + index.ts + Banner.tsx + Banner.test.tsx + Separator/ + index.ts + Separator.tsx + Separator.test.tsx + features/ + Auth/ + components/ + LoginForm/ + components/ + UsernameInput.tsx + PasswordInput.tsx + hooks/ + useLogin.ts + helpers/ + validateCredentials.ts + index.ts + LoginForm.tsx + LoginForm.test.tsx +``` + +- **Rule:** Each domain may contain its own components, hooks, utils, types, etc. related strictly to that domain/feature. +- **Rule:** Do not overexpose components/helpers/utils/hooks/types to higher levels when they are only useful for a specific domain. +- **Rule:** Only promote an entity to a higher level when strictly necessary. +- **Rule:** When possible, each domain should have an `index.ts` that exports the default entry point of that domain (example: `LoginForm/index.ts` exporting `LoginForm.tsx`). + +--- + +# Single-Responsibility Principle & File Organization + +- **Rule:** Files must have a single responsibility. Each file should contain exactly one component, hook, util, type, etc. +- **Rule:** Test files are an exception. They can group multiple tests or small helpers for the thing being tested. + +**Violation:** + +```tsx +/** +* Bad +* src/helpers/utils.ts +*/ + +export const formatDate = (date: Date): string; +export const calculateAge = (birthDate: Date): number; +export const isAdult = (age: number): boolean; +// etc... +``` + +**Correct (split into multiple files):** + +```tsx +/** +* Good +* src/helpers/formatDate.ts +*/ +export const formatDate = (date: Date): string; + +/** +* Good +* src/helpers/calculateAge.ts +*/ +export const calculateAge = (birthDate: Date): number; + +/** +* Good +* src/helpers/isAdult/ +* isAdult.ts +* isAdult.test.ts +*/ +``` + +- **Rule:** The default export of a file should be visible at first glance when opening the file. +- **Rule:** A file may contain a small number of internal helpers, but: + - They should be declared as **function expressions** (not hoisted arrow functions) to keep linting and hoisting behavior predictable. + - If there are more than 2 helpers, prefer splitting them into separate files inside the same domain/feature folder. + +**Violation:** + +```tsx +// Bad +// hooks/useComplexLogic.ts + +// helpers are preventing use to see the main export at first glance +const helper1 = () => { ... }; + +const helper2 = () => { ... }; + +// too many helpers in the same file, this is probably a big file that is hard to understand and maintain +const helper3 = () => { ... }; + +const useComplexLogic = () => { + // complex logic using helpers +}; + +export default useComplexLogic; +``` + +**Correct (helpers inline but minimal):** + +```tsx +// Good +// hooks/useComplexLogic/ +// useComplexLogic.ts + +// Main export is visible at first glance +const useComplexLogic = () => { + // complex logic using helpers +}; + +// small reusable helper inside the same file, useful to increase readability or semantics +// this helpers is too specialized to be reused outside this file +function helper1() { ... } + +export default useComplexLogic; +``` + +**Correct (helpers split out):** + +```tsx +// Good +// hooks/useComplexLogic/ +// useComplexLogic.ts +import helper1 from './helpers/helper1'; +import useSomething from './hooks/useSomething'; + +const useComplexLogic = () => { + // complex logic using helpers/hooks +}; + +export default useComplexLogic; +``` + +--- + +# React Component Guidelines + +This section defines explicit rules for how components and styling with CSS/MUI/Tailwind must be written. + +--- + +# Component Types & Reusability Rules + +- **Rule:** Reusable components must be: + - Generic + - Stateless (or at least UI-focused and not tied to a specific screen) + - Agnostic of any specific app/screen + - Located in `libs/ui` or `libs/common` +- **Rule:** Screen-specific components must be located in `frontend` or `backend`. + +**Correct reusable logic example:** + +```tsx +const publisherRef = useStableRef(() => { + return new OT.Publisher(...); +}, []); +// useStableRef is agnostic of any specific screen or app, so it can be reused anywhere. Candidate for libs/common +``` + +```tsx +const { result, error } = await tryCatch(() => fetchDataFromApi()); +// tryCatch is agnostic of any specific screen or app, so it can be reused anywhere. Candidate for libs/common +``` + +**Correct reusable UI example (libs/ui):** + +```tsx +// Reusable UI component candidate (libs/ui) +export const Dialog = ({ children }) => { + ... + return ( + + {children} + + ); +} +// A dialog is agnostic of any specific screen or app, so it can be reused anywhere. +``` + +**Correct screen-specific example (frontend):** + +```tsx +// Welcome page for the frontend app (frontend) +export const WelcomePager = () => { + return ( + + + + + ); +} +// The WelcomePager, Banner, WelcomeForm, PageLayout are specific to the frontend app and not reusable as generic components. +``` + +**Correct reusable UI example:** + +```tsx +export const RadioButton = ({ selected, onSelect, label }) => { + ... + return ( + + ); +} +// A RadioButton is a reusable UI component that can be used in multiple places. +``` + +**Correct domain-specific example (stays in frontend):** + +```tsx +// Form which uses RadioButton (frontend) +export const SurveyForm = () => { + ... + return ( +
        + + + + ); +} +// The SurveyForm is too specific, it can be reuse in certain domain but is not generic enough to be a reusable UI component. +``` + +--- + +# Async Logic & Suspense Usage Rules + +- **Rule:** `setState` + `useEffect` patterns should be rather used and strongly justified. Prefer linear non reactive code. +- **Rule:** Native `React.Suspense` and `React.use` must not be used directly. + Only `SuspenseBoundary` and `use$` which are boundary aware. +- **Rule:** Asynchronous operations must be handle gracefully, consider using skeletons or placeholders instead of spinners or loading indicators. Take also advantage of tools like `Suspense` and `useSuspenseMemo`. +- **Rule:** `use$` must only be used inside a `SuspenseBoundary` boundary; by design it will throw an explicit error otherwise. + +**Violation (async with state/effect):** + +```tsx +// Bad: using useEffect and setState for async operations +// the component is actually rendering event when data is not ready yet, this could cause flickering, unnecessary re-renders and inconsistent states, make the code harder to read and maintain +const MyComponent = () => { + const [data, setData] = useState(null); + + useEffect(() => { + fetchData().then((result) => setData(result)) + }, []); + + // Super Bad: Conditional rendering root element + if (!data) { + return
        Loading...
        ; + } + + // potentially need to validate nulls everywhere + return
          + {data?.map((item) => ( +
        • {item.name}
        • + ))} +
        ; +}; +``` + +**Correct (use$ and Suspense-aware pattern):** + +```tsx +// Good: uses Vera use$ hook and Suspense$ component for async operations +import use$ from '@ui/suspense/use$'; + +const fetchData = axios.get('https://some-api.com/data'); + +const MyComponent = () => { + // data is the awaited result of fetchData promise + // use$ is aware of the suspense context so it will throw if used outside a Suspense$ boundary + const data = use$(fetchData); + + return
          + {data.map((item) => ( +
        • {item.name}
        • + ))} +
        ; +}; +``` + +**Correct (useSuspenseMemo):** + +```tsx +// Good +import useSuspenseMemo from '@ui/suspense/useSuspenseMemo'; + +const MyComponent = () => { + // execute fetch data only once when the component is first rendered + const data = useSuspenseMemo(() => fetchData(), []); + + // Data is typed as the awaited result of fetchData promise, no need to validate nulls + return
          + {data.map((item) => ( +
        • {item.name}
        • + ))} +
        ; +}; +``` + +**Correct (combining nullable data and Suspense memo):** + +```tsx +// Good +import useSuspenseMemo from '@ui/suspense/useSuspenseMemo'; + +const MyComponent = () => { + const { data: nullableData, fetchData } = useSomeContext(); // data could be null + + // if data was already in place there it can be returning directly preventing unnecessary suspense + const data = useSuspenseMemo(() => { + if (isNil(nullableData)) return fetchData(); + return nullableData; + }, []); + + // Data is typed as the awaited result of fetchData promise, no need to validate nulls + return
          + {data.map((item) => ( +
        • {item.name}
        • + ))} +
        ; +}; +``` + +**Correct parent usage:** + +```tsx +// Parent component +import Suspense$ from '@ui/suspense/Suspense$'; + +const ParentComponent = () => { + return ( + // prefer skeleton instead of loading indicators/spinners +
        + ... + }> + + +
        + ); +}; +``` + +--- + +# React Context Guidelines + +- **Rule:** Do not create highly volatile contexts that change frequently and cause unnecessary re-renders. +- **Rule:** Context state should either: + - be stable enough that you do not need fine-grained granularity, or + - be granular enough that consumers can subscribe to specific portions of the state. +- **Rule:** Context APIs must not be reactive highly reactive. They should not re-render consumers unnecessarily. + +**Violation (manual React context for simple use case):** + +```tsx +// Bad creating unnecessary boilerplate for simple context +// dialogContext.tsx +import React, { useState, useCallback, useContext } from 'react'; + +// React plain context should start with null as default value to avoid misleading consumers +// So context cannot infer the type automatically +type DialogContextType = { + isOpen: boolean; + openDialog: () => void; + closeDialog: () => void; +}; + +const DialogContext = React.createContext(undefined); + +// Prefer not to use this kind of provider which mixes state and actions together +export const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsOpen(true); + }, []); + + const closeDialog = useCallback(() => { + setIsOpen(false); + }, []); + + return ( + + {children} + + ); +}; + +export const useDialog = (): DialogContextType => { + const context = useContext(DialogContext); + if (!context) { + throw new Error('useDialog must be used within a DialogProvider'); + } + return context; +}; +``` + +**Correct (using react-global-state-hooks):** + +```tsx +// Good: prefer use react-global-state-hooks/createContext to create simple contexts +import createContext from 'react-global-state-hooks/createContext'; + +// state and actions are separated so consumer can choose which portion of the context to subscribe to +export const dialog$ = createContext({ + isOpen: false, +}, { + actions: { + open() { + return ({ setState }) => { + // prefer use delta updates in case the state becomes bigger in the future + setState((state) => ({ ...state, isOpen: true })); + }; + }, + close() { + return ({ setState }) => { + setState((state) => ({ ...state, isOpen: false })); + }; + }, + }, +}); + +export default dialog$; + +// Provider component is automatically created + + {children} + +``` + +**Violation (mixed state and actions, no granularity):** + +```tsx +// Bad creating react context with several problems + +// state and actions are mixed so consumer cannot manipulate the context without subscribing to the whole state changes +// consumer cannot choose which portion of the context to subscribe to +const publisherContext = React.createContext<{ + publisher: OT.Publisher | null; + publisherOptions: PublisherOptions | null; + initializePublisher: (options: PublisherOptions) => void; + updatePublisherOptions: (options: Partial) => void; +}>(null); + +/** +* This hooks creates the context for the publisherContext.Provider +*/ +const usePublisher = () => { + const [publisher, setPublisher] = useState(null); + + // using state in context make it impossible to add granularity + const [publisherOptions, setPublisherOptions] = useState(null); + + // callbacks are memoized but the state changes will cause consumer re-renders even if they don't use the state + const initializePublisher = useCallback((options: PublisherOptions) => { + setPublisherOptions(options); + }, []); + + const updatePublisherOptions = useCallback((newOptions: Partial) => { + setPublisherOptions({ + ...prevOptions, + ...newOptions, + }); + }, []); + + // prefer to useEffect only for component lifecycle, not for reacting to state changes/side effects + // this whole useEffect is totally unnecessary, we can initialize the publisher directly in initializePublisher + useEffect(() => { + if (!publisherOptions) return; + + const newPublisher = OT.initPublisher(..., publisherOptions); + setPublisher(newPublisher); + }, [publisherOptions]); + + // this useMemo prevents to lose the state on parent re-renders, + // but will recompute the whole object on every state change which will cause unnecessary re-renders and potentially side effects on consumers making the useEffect useless + // context's API/actions are part of the state so consumer cannot use them subscribing to the state changes + return useMemo(() => ({ + publisher, + publisherOptions, + initializePublisher, + updatePublisherOptions, + }), [publisher, publisherOptions, initializePublisher, updatePublisherOptions]); +}; +``` + +**Correct (granular context using react-global-state-hooks):** + +```tsx +// Good: prefer use react-global-state-hooks/createContext to create granular contexts +// publisherContext.ts +import createContext, { InferContextApi } from 'react-global-state-hooks/createContext'; +import initializePublisher from './actions/initializePublisher'; +import updatePublisherOptions from './actions/updatePublisherOptions'; + +export type PublisherContextApi = InferContextApi; + +// Prefer to use a callback to initialize the default state to prevent multiples providers from sharing the same state reference +const publisher$ = createContext(() => ({ + publisher: null as OT.Publisher | null, + publisherOptions: null as PublisherOptions | null, +}), { + // for complex actions or big contexts, suggest to split actions into separate files + actions: { + initializePublisher, + updatePublisherOptions, + }, +}); + +// Prefer single responsibility files is aligned with splitting actions into separate files +// ./actions/initializePublisher.ts +function initializePublisher(publisherOptions: PublisherOptions) { + return ({ setState }) => { + const publisher = OT.initPublisher(publisherOptions); + + setState({ publisher, publisherOptions }); + }; +} + +// if using single responsibility files, prefer export default instead of named exports +export default initializePublisher; +``` + +```tsx +// ./actions/updatePublisherOptions.ts +// import the types in this way to avoid circular dependencies +export type PublisherContextApi = import('../publisherContext').PublisherContextApi; + +// all actions are bound to the context.actions object so they can access each other +function updatePublisherOptions(this: PublisherContextApi['actions'], updates: Partial) { + return ({ getState }) => { + const { publisherOptions } = getState(); + + this.initializePublisher({ + ...publisherOptions, + ...updates, + }); + }; +} + +export default updatePublisherOptions; + +// provider component is automatically created + + {children} + + +// context hook is automatically created and allows granular subscriptions +const publisherOptions = publisher$.use.select(({ publisherOptions }) => publisherOptions); + +// children can use actions without subscribing to state changes +const { initializePublisher } = publisher.use.actions(); + +// children can consume the whole context if needed +const [{ publisher, publisherOptions }, actions] = publisher$.use(); + +// prefer to create reusable selectors when accessing specific portions of the context is common +const usePublisherOptions = publisher$.use.createSelectorHook(({ publisherOptions }) => publisherOptions); + +// when using an specific property of an state or state derived value, prefer to use selectors. +// SomeComponent.tsx +const allowVideo = usePublisherOptions((options) => options.allowVideo); +``` + +- **Rule:** For `react-global-state-hooks` context, store names must use the `$` suffix (for example `dialog$`), and must not use the `Context` suffix in variable names. +- **Rule:** File names for context definitions must use the `Context` suffix (for example `dialogContext.tsx`). + +**Violation:** + +```tsx +// Bad +const dialogContext = createContext(...); +``` + +**Correct:** + +```tsx +// Good +const dialog$ = createContext(...); +``` + +**Violation (file name):** + +```tsx +// Bad +// dialog$.tsx +``` + +**Correct:** + +```tsx +// Good +// dialogContext.tsx +``` + +--- + +# Root Element, Visibility & Layout Behavior + +These rules define how the **root element** of a reusable component must behave for layout, styling, and customization. + +- **Rule:** Reusable components must allow 100% customization of the root element via props (`className`, `style`, other DOM props). + +**Violation:** + +```tsx +// Bad: no way to customize root element +export const Button: React.FC = ({ children }) => { + return ( + + ); +}; +``` + +**Correct:** + +```tsx +// Good: Allows full customization of root element +export const Button: React.FC>> = + ({ className, onClick, children, ...props }) => { + return ( + + ); + }; +``` + +- **Rule:** Root elements must not contain "hidden" layout hacks such as margins or paddings that affect how the component is placed in its surroundings. + +**Violation:** + +```tsx +// Bad: root element has hidden spacing that breaks reuse +export const UserCard = () => { + return ( +
        + ... +
        + ); +}; +``` + +**Correct (spacing owned by parent):** + +```tsx +// Good: root element is neutral, caller controls spacing +// UserCard can be placed anywhere without unexpected spacing +export const UserList = ({ users }) => { + return ( +
        + {users.map((user, index) => ( + // UserCard should not have extra margin so the parent/orchestrator can control spacing + + ))} +
        + ); +} +``` + +- **Rule:** Reusable components must never conditionally return `null`. Visibility must be controlled by the parent/orchestrator. +- **Rule:** If a component is present in JSX, it must render something (even if visually "empty"); parent controls whether to render the component at all. + +**Violation:** + +```tsx +// Bad: component sometimes returns null +export const SomeComponent = ({ ... }) => { + const isVisible = someLogic(...); + return isVisible ? + ); +}; + +const button = tv({ + base: 'rounded-sm font-medium transition active:opacity-80', + + variants: { + variant: { + primary: 'bg-blue-600 text-white hover:bg-blue-500', + secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300', + danger: 'bg-red-600 text-white hover:bg-red-500', + outline: 'border border-gray-300 bg-white text-gray-900 hover:bg-gray-100' + }, + + size: { + sm: 'text-sm px-3 py-1.5', + md: 'text-base px-4 py-2', + lg: 'text-lg px-6 py-3' + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md' + } +}); + +export default Button; + +// Usage +// Variants are semantic and predictable + + + + )} + + ); +}; +``` + +**Correct (composition with reusable building blocks):** + +```tsx +// Good: prefer composition with reusable building blocks +const VideoView = ({ children, ...props }) => { + const childrenArray = React.Children.toArray(children); + const controls = pickChild(childrenArray, VideoViewRegions.Controls); + + return ( + +
        + + {controls} +
        +
        + ); +}; + +VideoView.Controls = ControlsWrapper; +VideoView.MuteButton = MuteButton; +VideoView.EndCallButton = EndCallButton; + +export default VideoView; + +// Usage + + + + + + + +``` + +- **Rule:** When using composition, internal elements must be exposed as properties of the main component (e.g. `PageLayout.Header`, `VideoView.Controls`) to keep the API discoverable. +- **Rule:** Do not combine composition with heavy configuration-based rendering or noisy prop drilling to customize internal elements. + +- **Rule:** A reusable component may define its **internal layout** (flex/grid, internal padding) but must not impose external spacing. +- **Rule:** Internal spacing should be defined using gap/padding where appropriate and limited to the component’s internal layout. + +**Correct internal structure example:** + +```tsx +// Good internal structure +export function InfoRow({ label, value }) { + return ( + + {label} + {value} + + ); +} +``` + +--- + +# Conditional Rendering Rules + +- **Rule:** Do not use `style.display = 'none'` to hide components. +- **Rule:** If you need to hide a component while preserving its state, use `Activity` with `mode` instead of conditional display CSS. + +**Violation:** + +```tsx +// Bad: using style.display = 'none' to hide component +
        + +
        +``` + +**Correct:** + +```tsx +// Good: using React.Activity to hide component while preserving state + + + +``` + +- **Rule:** `Activity` will trigger effect cleanups but preserve component state while hidden. +- **Rule:** `Activity` is particularly useful for heavy components or ones that require expensive initialization (video/audio components). + +--- + +# React Hooks Rules & Effect Usage + +These rules define how reusable hooks must be written. + +- **Rule:** Prefer at most one plain `useEffect` per hook/component. +- **Rule:** If more than one effect is needed, split logic into smaller custom hooks to give each effect a clear semantic purpose. + +**Violation (multiple effects in one hook):** + +```tsx +// Bad: multiple useEffect inside a hook +// this is bad cause developers need to read and understand all the effects to understand the hook/component behavior +const useComplexLogic = () => { + useEffect(() => { + // effect 1 + }, [deps1]); + + useEffect(() => { + // effect 2 + }, [deps2]); +}; +``` + +**Correct (single effect per helper hook):** + +```tsx +// Good: single useEffect per hook/component +// this is good cause each hook/component has a single responsibility and its behavior is clear at first glance +type CleanupFunction = () => void; + +// simple hook uses useEffect to access component lifecycle +export const useWindowEvent = (event: string, handler: (e) => void) => { + const handlerWrapper = useEffectEvent(handler); + + useEffect(() => { + const abortController = new AbortController(); + + window.addEventListener(event, handlerWrapper, abortController); + + return () => abortController.abort(); + }, [event]); +} +``` + +**Correct (composing small hooks):** + +```tsx +// Good: semantic small hooks +// instead of having random use effects now we have semantic small hooks +export const useIsOnline = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useWindowEvent('online', () => setIsOnline(true)); + useWindowEvent('offline', () => setIsOnline(false)); + + return isOnline; +}; +``` + +```tsx +// Good: complex logic is split into multiple simple hooks +const useComplexLogic = () => { + const isOnline = useIsOnline(); + + useWindowEvent('resize', () => { + // handle resize + }); + + return ...; +}; +``` + +- **Rule:** Reactive effect architectures are banned. + - Effects must be preferred for component lifecycle, not as a state reaction graph for side effects like fetching. + +**Violation (reactive effect fetching):** + +```tsx +// Bad: useEffect used for side effects +// Bad: useEffect used as a "state reaction graph" for fetching +// Side effects are difficult to track and reason about, side effects could also get chained which makes it even more complex and unreliable. +const Component = () => { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [filters, setFilters] = useState({ search: '', sortBy: 'date' }); + const [page, setPage] = useState(1); + + // Bad: Implicit side effect, developers need to read the whole effect to understand what's happening + useEffect(() => { + setIsLoading(true); + + fetchItems({ filters, page }).then(result => { + setItems(result.items); + setIsLoading(false); + }); + }, [filters, page]); + + const handleSearchChange = (value: string) => { + // is not implicit what's happening after changing the filters + setFilters(prev => ({ ...prev, search: value })); + }; + + return ( + ... + ); +}; +``` + +**Correct (explicit linear flow):** + +```tsx +// Good: linear code no reactive effects +const Component = () => { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [filters, setFilters] = useState({ search: '', sortBy: 'date' }); + const [page, setPage] = useState(1); + + const loadItems = useCallback(async (nextFilters: Filters, nextPage: number) => { + setIsLoading(true); + + const result = await fetchItems({ filters: nextFilters, page: nextPage }); + + setItems(result.items); + setIsLoading(false); + }, []); + + // Initial load, this is component lifecycle not a reaction to state changes + // useOnMountEffect make the effect intention clear and semantically explicit + useOnMountEffect(() => { + loadItems(filters, page); + }); + + // Good stable callback for children props wont trigger shallow compare differences + const handleSearchChange = useStableCallback((value: string) => { + const nextFilters = { ...filters, search: value }; + + setFilters(nextFilters); + setPage(1); + + // what happens after changing the filters is explicit + loadItems(nextFilters, 1); + }); + + const handleNextPage = useStableCallback(() => { + const nextPage = page + 1; + + setPage(nextPage); + + // is clear and easy to identify why we are loading items + // developers can trace easily the call stack + loadItems(filters, nextPage); + }); + + return ( + ... + ); +}; +``` \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/instructions/test-files.instructions.md b/tutorials/vonage_video_ios_app-theme-customization/project/.github/instructions/test-files.instructions.md new file mode 100644 index 00000000..ab37b1a7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/instructions/test-files.instructions.md @@ -0,0 +1,149 @@ +--- +applyTo: "**/*.{spec,test}.{ts,tsx}" +--- + +# Test Authoring Instructions + +These instructions apply to all test files across the codebase (unit, integration, backend, libs). + +## Testing philosophy + +- Do not overtest. +- Small helpers/components with one or two relevant behaviors should usually have one condensed, high-value test that validates the behavior end-to-end. +- Prefer business logic tests with clear input and expected output. +- Do not add snapshot tests. Screenshot-based assertions in integration tests (Playwright `toHaveScreenshot`) are the only allowed exception. +- After writing a test, evaluate quality: + - Does it validate real functionality? + - Or is it mostly validating test tooling, mocks, or framework internals? +- Avoid tests that only check a mocked value was returned because it was mocked. + +## What to mock + +| Mock | Do not mock | +|------|-------------| +| External SDKs (e.g. `@vonage/client-sdk-video`) | Application contexts | +| Browser APIs (e.g. `navigator.mediaDevices`) | Custom hooks | +| | Components | + +## Mocking rules + +- Do not overmock. +- Prefer spies over full module mocking whenever possible. +- Avoid mocking our own logic when the scenario can be prepared using real parameters, real state, or real providers. +- Keep mocking minimal and focused on third-party dependencies or APIs not available in the test environment. +- For typed mocked functions, always use `vi.mocked(...)`. +- `as Mocked<...>` type-casting in test files is banned. + +## Provider/context testing rules + +- Do not mock context for our own providers when #sym:makeTestProvider can be used. +- Compose only the providers strictly necessary for the scenario. Every extra provider increases test setup cost and coupling. +- Initialize provider state using provider options to prepare the use case. +- Use returned public contexts to inspect values and interact with exposed actions. + +When a component requires providers, compose only what is needed: + +```tsx +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render( + ui: ReactElement, + { userContext, sessionContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session], + { userContext, sessionContext } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} +``` + +For hooks, the same principle applies: + +```tsx +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; +}; + +function renderHook( + render: (initialProps: Props) => Result, + { userContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider([providers.user], { + userContext, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} +``` + +## Async test rules + +- Do not use `setTimeout` or arbitrary `waitForTimeout` calls to make async tests pass. They slow the suite and hide real timing issues. +- Use `waitFor` from `@testing-library/react` to wait for observable state changes instead only when necessary. +- Every async test must declare an explicit assertion count with `expect.assertions(n)`. +- Place `expect.assertions(n)` at the start of the async test body so tests fail if execution exits before assertions run. + +## Shared test helpers + +Before adding custom setup, check and reuse existing helpers in: + +- `libs/common/test` +- `libs/common/testNode` +- `libs/common/testBrowser` + +Useful existing helpers include: + +- `libs/common/test/setup.ts` + - Already clears mocks, restores spies, and unstubs globals after each test. Already included in the global test setup — do not duplicate. +- `libs/common/testNode/helpers/waitForEvent.ts` + +### Banned boilerplate — already provided globally + +Do not call any of the following in individual test files. They are already invoked for every test run via the global setup files (`frontend/src/test/setup.ts`, `libs/*/test/setup.ts`): + +```ts +// Global cleanup — already runs after each test via mandatoryAfterEachCleanup() +cleanup(); +vi.clearAllMocks(); +vi.restoreAllMocks(); +vi.unstubAllGlobals(); +cancelablePromiseTracker.mockClear(); + +// Browser environment setup — already runs before each suite via setupFrontendTestEnvironment() +setupResizeObserverMock(); +setupScrollIntoViewMock(); +setupHtmlMediaElementGuards(); +setupHtmlCanvasElementGuards(); +setupCancelablePromiseHook(); +``` + +Duplicating these calls in test files adds noise and can cause double-invocation side effects. + - Useful for event-driven async tests. +- `libs/common/testBrowser/renderAsyncComponent.ts` + - Use for components that resolve async behavior with Suspense boundaries. +- `libs/common/testBrowser/renderAsyncHook.ts` + - Use for hooks that need async/Suspense-aware rendering. +- `libs/common/testBrowser/makeGenericProviderWrapper.tsx` + - Generic provider/context wrapper utility for reusable context testing. +- `libs/common/testBrowser/fixtures/setupWindowNavigatorMock` + - Browser navigator setup helpers for web media-related tests. + +Do not duplicate setup that these helpers already provide. + +## Test data setup + +- Avoid high-level shared variables when possible. +- Prefer creating scenario-specific inputs inside each test. +- Prefer fewer, more robust tests instead of many tiny tests that increase suite runtime with low value. +- Keep tests explicit and linear, with clear Arrange/Act/Assert intent. diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/pull_request_template.md b/tutorials/vonage_video_ios_app-theme-customization/project/.github/pull_request_template.md new file mode 100644 index 00000000..3000c9e6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/pull_request_template.md @@ -0,0 +1,17 @@ +#### What is this PR doing? + + +#### How should this be manually tested? + + +#### What are the relevant tickets? +A maintainer will add this ticket number. + +Resolves [VIDSOL-](https://jira.vonage.com/browse/VIDSOL-) + +#### Checklist +[ ] Branch is based on `develop` (not `main`). +[ ] Resolves a `Known Issue`. +[ ] If yes, did you remove the item from the `docs/KNOWN_ISSUES.md`? +[ ] Resolves an item reported in `Issues`. +If yes, which issue? [Issue Number](https://github.com/Vonage/vonage-video-react-app/issues/)? diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-set-base.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-set-base.yml new file mode 100644 index 00000000..9014d671 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-set-base.yml @@ -0,0 +1,32 @@ +name: Auto-set Base Branch to Develop + +on: + pull_request: + types: [opened] + +permissions: + contents: read + +jobs: + set-base-branch: + runs-on: ubuntu-22.04 + permissions: + pull-requests: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Check and Update Base Branch + env: + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + BASE_BRANCH: ${{ github.event.pull_request.head.ref }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [[ "$TARGET_BRANCH" == "main" && "$BASE_BRANCH" != "develop" && "$BASE_BRANCH" != rc-* ]]; then + echo "Switching base branch from $TARGET_BRANCH to develop..." + gh pr edit "$PR_NUMBER" --base "develop" + echo "Base branch updated to 'develop'." + else + echo "No changes needed: Target branch is '$TARGET_BRANCH' and base branch is '$BASE_BRANCH'." + fi diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-update-dependencies.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-update-dependencies.yml new file mode 100644 index 00000000..31a759ab --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/auto-update-dependencies.yml @@ -0,0 +1,43 @@ +name: auto-update-dependencies + +on: + push: + branches: + - develop + +permissions: + contents: read + +env: + BRANCH_NAME: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + +jobs: + run: + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install dependencies + run: yarn install --ignore-scripts --frozen-lockfile + - name: Run dependency update script + run: node scripts/generateDependenciesList.js + - name: Auto-commit updated dependencies file + run: | + echo "Committing docs/DEPENDENCIES.md changes..." + FILE="docs/DEPENDENCIES.md" + CONTENT=$(base64 -w 0 "$FILE") + SHA=$(curl -s -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/contents/$FILE?ref=$BRANCH_NAME" | jq -r .sha) + if [ "$SHA" == "null" ]; then + SHA="" + fi + curl -s -X PUT -H "Authorization: token $GITHUB_TOKEN" \ + -d "{\"message\":\"Commit updated dependencies file [auto-generated]\", \"content\":\"$CONTENT\", \"branch\":\"$BRANCH_NAME\", \"sha\":\"$SHA\"}" \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/contents/$FILE" diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/check-pull-request-title.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/check-pull-request-title.yml new file mode 100644 index 00000000..fd4f0147 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/check-pull-request-title.yml @@ -0,0 +1,34 @@ +name: check-pull-request-title + +on: + pull_request: + types: [edited, opened] + +permissions: + contents: read + +jobs: + run: + runs-on: ubuntu-22.04 + steps: + - name: Validate PR title + env: + PR_NAME: ${{ github.event.pull_request.title }} + run: | + set -e + + # Regex to check following: + # Starts with "VIDSOL-" followed by digits + # A colon (:) followed by exactly one space + # followed by the actual PR title text + regex="^VIDSOL-[0-9]+: [^ ].*$" + + if echo "$PR_NAME" | grep -Eq "$regex"; then + echo "Acceptable title" + exit 0 + else + echo "Rejected title" + echo "Reason: The PR title must start with 'VIDSOL-' followed by digits, a colon (:), and exactly one space, with the rest of the title immediately after the space." + exit 1 + fi + shell: bash diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/deploy-to-vcr.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/deploy-to-vcr.yml new file mode 100644 index 00000000..cedab90e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/deploy-to-vcr.yml @@ -0,0 +1,66 @@ +name: Deploy to VCR + +on: + pull_request: + branches: + - develop + types: [closed] + +permissions: + contents: read + +env: + DOMAIN: ${{secrets.DOMAIN}} + +jobs: + build-and-test: + if: github.event.pull_request.merged == true + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Verify Node & Yarn versions + run: | + node -v + yarn -v + + - name: Building artefact + run: | + sed -i "s//${DOMAIN}/g" "${{ github.workspace }}/vcr-gha.yml" + ./vcrBuild.sh + + - name: Install VCR CLI + uses: ./.github/actions/install-vcr-cli + + - name: Deploy vcr + uses: ./.github/actions/deploy-vcr + with: + app-id: ${{ secrets.APP_ID }} + api-key: ${{ secrets.VCR_API_KEY }} + api-secret: ${{ secrets.VCR_API_SECRET }} + filename: vcr-gha.yml + + - name: Slack notification on success + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: '28a745' + message: "Vera pull request ** \n with commit hash: ${{ github.sha }} \n is merged and deployed to VCR \n** \n :large_green_circle: :large_green_circle: :large_green_circle: :large_green_circle:" + if: success() + continue-on-error: true + + - name: Slack notification on failure + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FF0000 + message: "Vera pull request ** \n with commit hash: ${{ github.sha }} \n is merged but FAILED to deploy to VCR \n ** \n :red_circle: :red_circle: :red_circle: :red_circle:" + if: failure() + continue-on-error: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/lint.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/lint.yml new file mode 100644 index 00000000..1f6628ed --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/lint.yml @@ -0,0 +1,84 @@ +name: 👮 QA + +on: [pull_request, workflow_dispatch] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # ---------------------------------------------------- + # Code Quality Checks (Matrix) + # ---------------------------------------------------- + quality-checks: + name: ${{ matrix.check.name }} + runs-on: ubuntu-22.04 + permissions: + contents: read + + strategy: + matrix: + check: + - name: ESLint + command: yarn lint + - name: Prettier + command: yarn prettier . --check --log-level warn + - name: TypeScript Check + command: yarn ts-check + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install Dependencies + run: yarn install --ignore-scripts --frozen-lockfile + + - name: Run ${{ matrix.check.name }} + run: ${{ matrix.check.command }} + + # ---------------------------------------------------- + # Filename Linting + # ---------------------------------------------------- + lint-filenames: + name: Filename Linting + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check that new files are camelCase + run: ./scripts/lintFileNames.sh + + # ---------------------------------------------------- + # License Check + # ---------------------------------------------------- + license-check: + name: License Check + runs-on: ubuntu-22.04 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install Dependencies + run: yarn install --ignore-scripts --frozen-lockfile + + - name: Run license check + run: | + npx license-checker --production --json > scripts/allLicenseResults.json + node scripts/licenseCheck.js diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/release-notification.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/release-notification.yml new file mode 100644 index 00000000..b7a1b9e3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/release-notification.yml @@ -0,0 +1,35 @@ +name: Notify Slack on Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + notify-release: + runs-on: ubuntu-22.04 + name: Notify Release + steps: + - name: Send to slack channels + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + errors: true + payload: | + blocks: + - type: "header" + text: + type: "plain_text" + text: ":tada: Version ${{ github.event.release.name }} of the Vonage Video Reference App Just Released!" + - type: "section" + text: + type: "mrkdwn" + text: "${{ github.event.release.body }}" + - type: "divider" + - type: "section" + text: + type: "mrkdwn" + text: "You can view the full change log <${{github.event.release.html_url }}|here>" diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/run-tests.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/run-tests.yml new file mode 100644 index 00000000..bbe05069 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/run-tests.yml @@ -0,0 +1,186 @@ +name: 🧑‍💻 + +on: [pull_request, workflow_dispatch] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +env: + VIDEO_SERVICE_PROVIDER: ${{vars.VIDEO_SERVICE_PROVIDER}} + OT_API_KEY: ${{secrets.API_KEY}} + OT_API_SECRET: ${{secrets.API_SECRET}} + VONAGE_APP_ID: ${{secrets.VONAGE_APP_ID}} + VONAGE_PRIVATE_KEY: ${{secrets.VONAGE_PRIVATE_KEY}} + SESSION_KEY_SECRET: ${{secrets.SESSION_KEY_SECRET}} + +jobs: + # ---------------------------------------------------- + # Unit Tests (Matrix) + # ---------------------------------------------------- + unit-tests: + name: Unit Tests - ${{ matrix.project }} + runs-on: ubuntu-22.04 + permissions: + contents: read + + strategy: + matrix: + project: [frontend, backend, ui, core, common, api] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install Dependencies + run: yarn install --ignore-scripts --frozen-lockfile + + - name: Run ${{ matrix.project }} unit tests + run: NX_DAEMON=false yarn nx test ${{ matrix.project }} --configuration=coverage + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.project }} + path: | + ${{ matrix.project }}/coverage/ + libs/${{ matrix.project }}/coverage/ + retention-days: 1 + + # ---------------------------------------------------- + # Integration Tests (Playwright) + # ---------------------------------------------------- + integration-tests: + name: 🤖 E2E / ${{ matrix.browser.name }} + runs-on: ubuntu-22.04 + if: github.event.pull_request.head.repo.fork == false + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + browser: + - name: Chrome + project: Google Chrome Fake Devices + - name: Firefox + project: firefox + - name: Mobile Chrome + project: Mobile Chrome + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install chrome latest + if: matrix.browser.name != 'Firefox' + run: | + wget --max-redirect=0 -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo sh -c 'echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' + sudo apt update && sudo apt install -y google-chrome-stable + echo "Installed Chrome:" + /usr/bin/google-chrome-stable --version + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install Dependencies + run: | + node -v + yarn install --ignore-scripts --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-browsers-${{ hashFiles('**/package.json') }} + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run integration tests + run: | + echo "VIDEO_SERVICE_PROVIDER: $VIDEO_SERVICE_PROVIDER" + Xvfb :99 & export DISPLAY=:99 + cd integration-tests && npx playwright test --project='${{ matrix.browser.project }}' + + - name: Upload Playwright Report + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-${{ matrix.browser.name }} + path: integration-tests/playwright-report/ + retention-days: 7 + + # ---------------------------------------------------- + # SonarCloud Analysis + # ---------------------------------------------------- + sonarcloud: + name: SonarCloud + runs-on: ubuntu-22.04 + needs: [unit-tests] + if: github.event.pull_request.head.repo.fork == false + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: Install Dependencies + run: | + node -v + yarn install --ignore-scripts --frozen-lockfile + + - name: Download all coverage artifacts + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: false + + - name: List coverage files + run: | + echo "Listing coverage files:" + find . -name "lcov.info" -type f + ls -la coverage-* || true + + - name: SonarCloud Scan On PR + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} + -Dsonar.pullrequest.branch=${{ github.head_ref }} + -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} + + - name: SonarCloud Branch Analysis On base branch + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.branch.name=${{ github.event.pull_request.base.ref }} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-deploy-on-comment.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-deploy-on-comment.yml new file mode 100644 index 00000000..415f4f0e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-deploy-on-comment.yml @@ -0,0 +1,148 @@ +name: Deploy PR to VCR on comment + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: PR number to deploy + required: true + type: string + +permissions: + contents: read + pull-requests: write + issues: write + +env: + DOMAIN: ${{ secrets.DOMAIN }} + +jobs: + build-and-test: + name: Deploy to VCR (comment trigger) + # Run only when: + # - comment is on a PR + # - comment body is exactly 'vcr:deploy' + if: > + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == 'vcr:deploy') || + (github.event_name == 'workflow_dispatch') + runs-on: ubuntu-22.04 + steps: + - name: Load PR details + id: pr + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.issue?.number ?? Number(context.payload.inputs?.pr_number); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + core.setOutput('number', String(pr.number)); + core.setOutput('title', pr.title || ''); + core.setOutput('head_sha', pr.head.sha); + core.setOutput( + 'is_fork', + String(pr.head.repo.full_name !== pr.base.repo.full_name) + ); + + - name: Block forks + if: steps.pr.outputs.is_fork == 'true' + run: | + echo "Deployment from forked repositories is not allowed." + exit 1 + + - name: Checkout PR HEAD SHA + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head_sha }} + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Verify Node & Yarn versions + run: | + node -v + yarn -v + + # Create dev VCR config, patch instance name, build artefact + - name: Building artefact + run: | + # Add empty env file to avoid errors during build + touch "${{ github.workspace }}/backend/.env" + + # Create a dev-specific VCR config file + cp "${{ github.workspace }}/vcr-gha.yml" "${{ github.workspace }}/vcr-dev.yml" + + # REMOVE the domain line entirely for comment-based deploys + sed -i '/^[[:space:]]*domains:/d' "${{ github.workspace }}/vcr-dev.yml" + + # Patch instance name to be PR-specific + sed -i \ + "/^instance:/ {n; s/^ name: .*/ name: vera-pr-${{ steps.pr.outputs.number }}/;}" \ + "${{ github.workspace }}/vcr-dev.yml" + + ./vcrBuild.dev.sh + + - name: Install VCR CLI + uses: ./.github/actions/install-vcr-cli + + - name: Deploy vcr + id: deploy + uses: ./.github/actions/deploy-vcr + with: + app-id: ${{ secrets.APP_ID }} + api-key: ${{ secrets.VCR_API_KEY }} + api-secret: ${{ secrets.VCR_API_SECRET }} + filename: vcr-dev.yml + + - name: Comment PR on success + if: success() + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr.outputs.number }}, + body: `## ✅ Deployed to VCR\n\n**URL:** ${{ steps.deploy.outputs.instance-url }}\n\n**Commit:** \`${{ steps.pr.outputs.head_sha }}\`\n\n[View build logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})` + }); + continue-on-error: true + + - name: Slack notification on success + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: '28a745' + message: "Vera pull request ** \n with commit hash: ${{ steps.pr.outputs.head_sha }} \n is deployed to VCR \nURL: ${{ steps.deploy.outputs.instance-url }} \n** \n :large_green_circle: :large_green_circle: :large_green_circle: :large_green_circle:" + if: success() + continue-on-error: true + + - name: Comment PR on failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.pr.outputs.number }}, + body: `## ❌ Deployment to VCR Failed\n\n**Commit:** \`${{ steps.pr.outputs.head_sha }}\`\n\n[View build logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})` + }); + continue-on-error: true + + - name: Slack notification on failure + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FF0000 + message: "Vera pull request ** \n with commit hash: ${{ steps.pr.outputs.head_sha }} \n FAILED to deploy to VCR \n ** \n :red_circle: :red_circle: :red_circle: :red_circle:" + if: failure() + continue-on-error: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-non-use-instance.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-non-use-instance.yml new file mode 100644 index 00000000..f888518a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-non-use-instance.yml @@ -0,0 +1,91 @@ +name: Remove PR VCR instance on close + +on: + pull_request: + types: [closed] + +permissions: + contents: read + pull-requests: read + issues: write + +jobs: + remove-vcr: + name: Remove VCR instance + runs-on: ubuntu-22.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install VCR CLI + uses: ./.github/actions/install-vcr-cli + + - name: Remove VCR instance (tolerant) + id: remove + shell: bash + env: + VCR_API_KEY: ${{ secrets.VCR_API_KEY }} + VCR_API_SECRET: ${{ secrets.VCR_API_SECRET }} + run: | + set -euo pipefail + + PROJECT_NAME="vonage-video-react-app" + INSTANCE_NAME="vera-pr-${{ github.event.pull_request.number }}" + + echo "instance_name=$INSTANCE_NAME" >> "$GITHUB_OUTPUT" + + set +e + vcr instance remove \ + --project-name "$PROJECT_NAME" \ + --instance-name "$INSTANCE_NAME" \ + --api-key "$VCR_API_KEY" \ + --api-secret "$VCR_API_SECRET" \ + --region aws.euw1 \ + --graphql-endpoint https://graphql.euw1.runtime.vonage.cloud/v1/graphql \ + 2>&1 | tee remove-vcr-logs.log + RC=${PIPESTATUS[0]} + set -e + + # Removed successfully + if [ "$RC" -eq 0 ]; then + echo "result=removed" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Instance did not exist -> non-fatal + if grep -Eqi 'not found|does not exist|no such|404' remove-vcr-logs.log; then + echo "result=missing" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Real failure + echo "result=failed" >> "$GITHUB_OUTPUT" + exit 1 + + - name: Slack notification on removed + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: '28a745' + message: "Vera pull request ** \n with commit hash: ${{ github.sha }} \n had instance removed from VCR \nInstance: `${{ steps.remove.outputs.instance_name }}` \n** \n :large_green_circle: :large_green_circle: :large_green_circle: :large_green_circle:" + if: steps.remove.outputs.result == 'removed' + continue-on-error: true + + - name: Slack notification on no instance found + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FFA500 + message: "Vera pull request ** \n with commit hash: ${{ github.sha }} \n had no VCR instance to remove \nInstance: `${{ steps.remove.outputs.instance_name }}` \n**" + if: steps.remove.outputs.result == 'missing' + continue-on-error: true + + - name: Slack notification on failure + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FF0000 + message: "Vera pull request ** \n with commit hash: ${{ github.sha }} \n FAILED to remove instance from VCR \nInstance: `${{ steps.remove.outputs.instance_name }}` \n ** \n :red_circle: :red_circle: :red_circle: :red_circle:" + if: failure() + continue-on-error: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-on-comment.yml b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-on-comment.yml new file mode 100644 index 00000000..6af3d163 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.github/workflows/vcr-remove-on-comment.yml @@ -0,0 +1,129 @@ +name: Remove PR VCR instance on comment + +on: + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: PR number to remove + required: true + type: string + +permissions: + contents: read + pull-requests: read + issues: write + +jobs: + remove-vcr: + name: Remove VCR instance (comment trigger) + # Run only when: + # - comment is on a PR + # - comment body is exactly 'vcr:remove' + if: > + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body == 'vcr:remove') || + (github.event_name == 'workflow_dispatch') + runs-on: ubuntu-22.04 + steps: + - name: Load PR details + id: pr + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.issue?.number ?? Number(context.payload.inputs?.pr_number); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + core.setOutput('number', String(pr.number)); + core.setOutput('title', pr.title || ''); + core.setOutput('head_sha', pr.head.sha); + core.setOutput( + 'is_fork', + String(pr.head.repo.full_name !== pr.base.repo.full_name) + ); + + - name: Block forks + if: steps.pr.outputs.is_fork == 'true' + run: | + echo "Removal from forked repositories is not allowed." + exit 1 + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install VCR CLI + uses: ./.github/actions/install-vcr-cli + + - name: Remove VCR instance (tolerant) + id: remove + shell: bash + env: + VCR_API_KEY: ${{ secrets.VCR_API_KEY }} + VCR_API_SECRET: ${{ secrets.VCR_API_SECRET }} + run: | + set -euo pipefail + + PROJECT_NAME="vonage-video-react-app" + INSTANCE_NAME="vera-pr-${{ steps.pr.outputs.number }}" + + echo "instance_name=$INSTANCE_NAME" >> "$GITHUB_OUTPUT" + echo "Removing instance: $INSTANCE_NAME" + + set +e + vcr instance remove \ + --project-name "$PROJECT_NAME" \ + --instance-name "$INSTANCE_NAME" \ + --api-key "$VCR_API_KEY" \ + --api-secret "$VCR_API_SECRET" \ + --region aws.euw1 \ + --graphql-endpoint https://graphql.euw1.runtime.vonage.cloud/v1/graphql \ + 2>&1 | tee remove-vcr-logs.log + RC=${PIPESTATUS[0]} + set -e + + if [ "$RC" -eq 0 ]; then + echo "result=removed" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Treat "not found" as non-fatal + if grep -Eqi 'not found|does not exist|no such|404' remove-vcr-logs.log; then + echo "result=missing" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "result=failed" >> "$GITHUB_OUTPUT" + exit 1 + + - name: Slack notification on removed + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: '28a745' + message: "Vera pull request ** \n with commit hash: ${{ steps.pr.outputs.head_sha }} \n had instance removed from VCR \nInstance: `${{ steps.remove.outputs.instance_name }}` \n** \n :large_green_circle: :large_green_circle: :large_green_circle: :large_green_circle:" + if: steps.remove.outputs.result == 'removed' + continue-on-error: true + + - name: Slack notification on no instance found + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FFA500 + message: "Vera pull request ** \n with commit hash: ${{ steps.pr.outputs.head_sha }} \n had no VCR instance to remove \nInstance: `${{ steps.remove.outputs.instance_name }}` \n**" + if: steps.remove.outputs.result == 'missing' + continue-on-error: true + + - name: Slack notification on failure + uses: ./.github/actions/slack-notify + with: + slack-bot-token: ${{ secrets.SLACK_NOTIFICATION }} + color: FF0000 + message: "Vera pull request ** \n with commit hash: ${{ steps.pr.outputs.head_sha }} \n FAILED to remove instance from VCR \nInstance: `${{ steps.remove.outputs.instance_name }}` \n ** \n :red_circle: :red_circle: :red_circle: :red_circle:" + if: failure() + continue-on-error: true diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.gitignore b/tutorials/vonage_video_ios_app-theme-customization/project/.gitignore new file mode 100644 index 00000000..6aa07aac --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.gitignore @@ -0,0 +1,66 @@ +# ignore techdocs output folder +generated-docs/ +frontend/docs/ + +**/node_modules/* + +**/.env +dist/ +**/dist/ + +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +.DS_Store +*.env + +pids +logs +results + +node_modules +npm-debug.log +bower_components + +public/js/* + +server.key +server.crt +server.csr + +dump.rdb +coverage/ +/coverage/* +OpenTok-iOS-2.4.0/ +opentok-ios-sdk-* +/BrowserStackLocal +/sc-*/* +sauce-connect.log + +.tern-project + +.vscode/settings.json + +build + +/scripts/allLicenseResults.json + +vcr.yml +.scannerwork/ + +.nx/cache +.nx/workspace-data +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md +vite.config.*.timestamp* +vitest.config.*.timestamp* +*.tsbuildinfo +vcr-dev.yml + +/frontend/distRoom +room.zip diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-commit b/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-commit new file mode 100755 index 00000000..afa3f0c2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +# skip on CI or if deps aren't installed yet +[ -n "${CI:-}" ] && exit 0 + +# Run your quality checks +yarn --silent quality-check diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-push b/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-push new file mode 100755 index 00000000..d2893157 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.husky/pre-push @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +# Skip on CI +[ -n "${CI:-}" ] && exit 0 + +echo "🔍 Running tests before push..." +yarn --silent test diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.nvmrc b/tutorials/vonage_video_ios_app-theme-customization/project/.nvmrc new file mode 100644 index 00000000..35d2d08e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.nvmrc @@ -0,0 +1 @@ +22.12 diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.prettierignore b/tutorials/vonage_video_ios_app-theme-customization/project/.prettierignore new file mode 100644 index 00000000..8eadedc2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.prettierignore @@ -0,0 +1,20 @@ +# Generated or irrelevant files +*.log +frontend/docs/ + +# Documentation [todo]: We should be able to use prettier for this to ensure consistent formatting +*.md + +# Build and dependency folders +dist/ +node_modules/ +coverage/ +.nx/ +.turbo/ +integration-tests/node_modules/ +integration-tests/playwright-report/ +integration-tests/test-results/ + +# Public compiled assets +public/js/ + diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.prettierrc b/tutorials/vonage_video_ios_app-theme-customization/project/.prettierrc new file mode 100644 index 00000000..789ac2e5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vcrignore b/tutorials/vonage_video_ios_app-theme-customization/project/.vcrignore new file mode 100644 index 00000000..9a5eb5cb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vcrignore @@ -0,0 +1,2 @@ +vcr-dev.yml +vcr-gha.yml diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vonage/catalog-info.yaml b/tutorials/vonage_video_ios_app-theme-customization/project/.vonage/catalog-info.yaml new file mode 100644 index 00000000..aedf61b2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vonage/catalog-info.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: video-react-ref-app # SERVICE NAME field + title: Video React Ref App # Title (human-friendly library name) field + description: Vonage Video API JS React reference application + annotations: + vonage.com/jira-id: VPF # video general projects + vonage.com/maintainers: | + - Hossein Movahed @Hossein-Movahed + - Johnny Esteban Quesada @johnny-quesada-developer + - Oscar Fava @OscarFava +spec: + type: library + owner: group:orgdata/vdk # helm "owner" orgdata squad id or general orgdata/vid + domain: video-api # Product this library belongs to +--- +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: video-react-ref-app + description: Video React Ref App Component + annotations: + github.com/project-slug: Vonage/vonage-video-react-app # GH repo + vonage.com/maintainers: | + - Hossein Movahed @Hossein-Movahed + - Johnny Esteban Quesada @johnny-quesada-developer + - Oscar Fava @OscarFava +spec: + type: library + owner: group:orgdata/vdk + system: video-react-ref-app + lifecycle: production + dependsOn: + - component:default/video-js-sdk diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vonage/deploy/.gitkeep b/tutorials/vonage_video_ios_app-theme-customization/project/.vonage/deploy/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/extensions.json b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/extensions.json new file mode 100644 index 00000000..2018509b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "streetsidesoftware.code-spell-checker", + "naumovs.color-highlight", + "nrwl.angular-console", + "ms-playwright.playwright", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "github.copilot-chat" + ] +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/launch.json b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/launch.json new file mode 100644 index 00000000..cefbee98 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to Backend", + "port": 9229, + "restart": true, + "skipFiles": ["/**", "**/node_modules/**"] + } + ] +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.contextStore.code-snippets b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.contextStore.code-snippets new file mode 100644 index 00000000..a7b2747f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.contextStore.code-snippets @@ -0,0 +1,53 @@ +{ + "Context Store": { + "scope": "typescriptreact,typescript", + "prefix": "contextStore", + "description": "React global state context store template", + "body": [ + "import { createContext, type InferAPI } from 'react-global-state-hooks';", + "", + "export type ${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/upcase}$2/}Api = InferAPI;", + "", + "const ${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/downcase}$2/}\\$ = createContext(initialValue, {", + " name: '${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/downcase}$2/}',", + " metadata,", + "", + " /**", + " * Define your actions here. Or delete this section if you don't have any actions.", + " */", + " // actions: {", + " // someAction(...args) {", + " // return (api) => {", + " // const { getState, setState } = api as ${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/upcase}$2/}Api;", + " // // your logic goes here...", + " // };", + " // },", + " // },", + "", + " /**", + " * Define your lifecycle callbacks here. Or delete this section if you don't have any callbacks.", + " */", + " // callbacks: {", + " // onInit() {", + " // const api = this as ${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/upcase}$2/}Api;", + " // // your logic goes here...", + " // },", + " // },", + "});", + "", + "function initialValue() {", + " return {", + " // Add your initial state properties here", + " };", + "}", + "", + "function metadata() {", + " return {", + " // Add your metadata properties here", + " };", + "}", + "", + "export default ${TM_FILENAME_BASE/^(.)(.*?)\\$?$/${1:/downcase}$2/}\\$;$0", + ], + }, +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.reactComponent.code-snippets b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.reactComponent.code-snippets new file mode 100644 index 00000000..34ebf752 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.vscode/vera.reactComponent.code-snippets @@ -0,0 +1,20 @@ +{ + "React Component": { + "scope": "typescriptreact", + "prefix": "reactComponent", + "description": "Vera react component template with tailwind", + "body": [ + "import classNames from 'classnames';", + "import type { ComponentProps, FC } from 'react';", + "import { twMerge } from 'tailwind-merge';", + "", + "type ${TM_FILENAME_BASE}Props = ComponentProps<'${1:div}'> & {$2};", + "", + "const ${TM_FILENAME_BASE}: FC<${TM_FILENAME_BASE}Props> = ({ className, ...props }) => {", + " return <${1:div} className={twMerge(classNames('${TM_FILENAME_BASE}', className))} {...props}$0 />;", + "};", + "", + "export default ${TM_FILENAME_BASE};", + ], + }, +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/.windsurfrules b/tutorials/vonage_video_ios_app-theme-customization/project/.windsurfrules new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/.windsurfrules @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/AGENTS.md b/tutorials/vonage_video_ios_app-theme-customization/project/AGENTS.md new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/CLAUDE.md b/tutorials/vonage_video_ios_app-theme-customization/project/CLAUDE.md new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/CLAUDE.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/CODEX.md b/tutorials/vonage_video_ios_app-theme-customization/project/CODEX.md new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/CODEX.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/CODE_OF_CONDUCT.md b/tutorials/vonage_video_ios_app-theme-customization/project/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d765a13f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [devrel@vonage.com](mailto:devrel@vonage.com). All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at `https://www.contributor-covenant.org/version/2/0/code_of_conduct.html`. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +`https://www.contributor-covenant.org/faq`. Translations are available at `https://www.contributor-covenant.org/translations`. diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/GEMINI.md b/tutorials/vonage_video_ios_app-theme-customization/project/GEMINI.md new file mode 120000 index 00000000..02dd1341 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/GEMINI.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/LICENSE b/tutorials/vonage_video_ios_app-theme-customization/project/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/MAINTAINERS.md b/tutorials/vonage_video_ios_app-theme-customization/project/MAINTAINERS.md new file mode 100644 index 00000000..fe1ec4ed --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/MAINTAINERS.md @@ -0,0 +1,44 @@ +# Maintainers + +This repository is actively maintained by Vonage engineers. + +## Current Maintainers + +| Role | Name | +|---|---| +| Full Stack | Hossein Movahed ([@Hossein-Movahed](https://github.com/Hossein-Movahed)) | +| Full Stack | Johnny Esteban Quesada ([@johnny-quesada-developer](https://github.com/johnny-quesada-developer)) | +| Full Stack | Oscar Fava ([@OscarFava](https://github.com/OscarFava)) | + +## Responsibilities + +Maintainers are responsible for: + +- Reviewing and approving pull requests +- Maintaining code quality, consistency, and CI stability +- Managing releases and dependency updates +- Triaging issues and discussions +- Ensuring contributions align with the goals and long-term direction of the project +- Evaluating architectural, performance, operational, and product impact of proposed changes + +## Pull Request Expectations + +- At least one maintainer approval is required before merge +- CI must pass before merge +- Breaking changes should be discussed before implementation +- Pull requests should align with the project's scope, maintainability standards, and long-term goals +- Large features should be submitted in isolated pull requests when possible +- Maintainers may request scope reduction or architectural changes to keep the project maintainable and lightweight + +## Project Direction + +Maintainers are responsible not only for reviewing code correctness, but also for evaluating the broader impact of changes on: + +- Bundle size and application performance +- Mobile and low-bandwidth user experience +- Dependency footprint and operational complexity +- Distribution and embeddability constraints +- Developer experience and maintainability +- Product consistency and UI/UX direction + +Contributions may be declined or requested to be re-scoped if they introduce significant architectural, performance, or product tradeoffs that do not align with the current direction of the project. \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/README.md b/tutorials/vonage_video_ios_app-theme-customization/project/README.md new file mode 100644 index 00000000..316d867f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/README.md @@ -0,0 +1,619 @@ +# Vonage Video API Reference App for React + +Nexmo is now known as Vonage + +## Welcome to Vonage + +If you're new to Vonage, you can [sign up for a Vonage API account](https://dashboard.vonage.com/?utm_source=DEV_REL&utm_medium=github&utm_campaign=vonage-video-react-app) and get some free credit to get you started. + +## Table of Contents + + +- [Table of Contents](#table-of-contents) +- [What is it?](#what-is-it) +- [Cross-Platform Support](#cross-platform-support) +- [Why use it?](#why-use-it) +- [Project Architecture](#project-architecture) +- [Platforms Supported](#platforms-supported) +- [Requirements](#requirements) +- [Running Locally](#running-locally) +- [Storybook](#storybook) +- [UI Customization](#ui-customization) +- [Environment Configuration](#environment-configuration) +- [Testing on Multiple Devices](#testing-on-multiple-devices) +- [Deployment to Vonage Cloud Runtime](#deployment-to-vonage-cloud-runtime) +- [Testing](#testing) + - [Integration Tests](#integration-tests) + - [Screenshot tests or Visual comparisons](#screenshot-tests-or-visual-comparisons) + - [Generating and Updating Screenshots](#generating-and-updating-screenshots) + - [Running the backend and frontend test suites](#running-the-backend-and-frontend-test-suites) + - [Backend Suite](#backend-suite) + - [Frontend Suite](#frontend-suite) +- [Code style](#code-style) + - [Linting and auto-formatting](#linting-and-auto-formatting) + - [File names](#file-names) +- [Documentation Generation](#documentation-generation) +- [Code of Conduct](#code-of-conduct) +- [Maintainers](#maintainers) +- [Getting Involved](#getting-involved) +- [Known Issues](#known-issues) +- [Report Issues](#report-issues) + + +## What is it? +The Vonage Video API Reference App for React is an open-source video conferencing reference application for the [Vonage Video API](https://developer.vonage.com/en/video/client-sdks/web/overview) using the React framework. + +The Reference App demonstrates the best practices for integrating the [Vonage Video API](https://developer.vonage.com/en/video/client-sdks/web/overview) with your application for various use cases, from one-to-one and multi-participant video calling to recording, screen sharing, reactions, and more. + +## Why use it? +The Vonage Video API Reference App for React provides developers an easy-to-setup way to get started with using our APIs with React. + +The application is open-source, so you can not only get started quickly, but easily extend it with features needed for your use case. Any features already implemented in the Reference App use best practices for scalability and security. + +As a commercial open-source project, you can also count on a solid information security architecture. While no packaged solution can guarantee absolute security, the transparency that comes with open-source software, combined with the proactive and responsive open-source community and vendors, provides significant advantages in addressing information security challenges compared to closed-source alternatives. + +This application provides features for common conferencing use cases, such as: + +-
        + A landing page for users to create and join meeting rooms. + Screenshot of landing page +
        +-
        + A waiting room for users to preview their audio and video device settings and set their name before entering a meeting room. + Screenshot of waiting room +
        +-
        + A post-call page to navigate users to the landing page, re-enter the left room, and display archive(s), if any. + Screenshot of goodbye page +
        +- A video conferencing “room” supporting up to 25 participants and the following features: +-
        + Input and output device selectors. + Screenshot of audio devices selector +
        +-
        + Noise suppression toggles in meeting room + Screenshot of noise supression toggle +
        +-
        + + Video effects in meeting and waiting room. You can set predefined images, custom image or slight/strong background blur. Images can be uploaded from local device or URL in these formats: JPG, PNG, GIF or BMP. Video effects are not supported in non-Chromium-based browsers or on iOS. + + Please see [OT.hasMediaProcessorSupport](https://vonage.github.io/video-docs/video-js-reference/latest/OT.html#hasMediaProcessorSupport) for more information. + + + Screenshot of video effects +
        +-
        + Composed archiving capabilities to record your meetings. + Screenshot of archiving dialog box +
        +-
        + In-call tools such as screen sharing (subscriber can zoom in/out if hasMediaProcessorSupport), group chat function, and emoji reactions. + Screenshot of emojis +
        +- Active speaker detection. +- Layout manager with options to display active speaker, screen share, or all participants in a grid view. +- The dynamic display adjusts to show new joiners, hide video tiles to conserve bandwidth, and show the “next” participant when someone previously speaking leaves. +-
        + Ability to mute other participants during the meeting. + Screenshot of muting participant dialog box +
        +-
        + Call participant list with audio on/off indicator. + Screenshot of participant list +
        +- Meeting information with an easy-to-share URL to join the meeting. +-
        + A reporting tool to enable participants to file any in-call issues. + Screenshot of report issue pane +
        + +## Project Architecture + +The project uses an Nx workspace to manage the frontend and backend applications. + +![Vonage Video API Reference App Architecture Diagram](./docs/assets/project-architecture.svg) + +## Platforms Supported +The Vonage Video API Reference App for React is currently supported on the latest release versions for the following browsers: +- ![Chrome icon](/docs/assets/chrome.svg) Google Chrome +- ![Firefox icon](/docs/assets/ff.svg) Firefox +- ![Edge icon](/docs/assets/edge.svg) Microsoft Edge +- ![Opera icon](/docs/assets/opera.svg) Opera +- ![Safari icon](/docs/assets/safari.svg) Safari + +*Note:* Some browsers such as Firefox or Safari do not support media processors like video and audio filters (e.g video effects): Please see [OT.hasMediaProcessorSupport](https://vonage.github.io/video-docs/video-js-reference/latest/OT.html#hasMediaProcessorSupport) for more information. + +*Note:* Mobile web views have limited supported at the moment. The minimum supported device width is `360px`. + +## Cross-Platform Support +Looking to build on other platforms? The Vonage Video API Reference App is also available for: + +- *iOS*: [vonage-video-ios-app](https://github.com/Vonage/vonage-video-ios-app) +- *Android*: [vonage-video-android-app](https://github.com/Vonage/vonage-video-android-app) + +These reference apps share the same backend infrastructure and demonstrate consistent best practices across all platforms, making it easy to build unified video experiences for your users. + +## Requirements + + +- [node.js](https://nodejs.org/en/download/releases/) (version 22) +- [yarn](https://yarnpkg.com) +- optional - [nvm](https://github.com/creationix/nvm) (recommended for switching Node versions) + +## Running Locally + +- **Ensure You Have a Vonage Account** + + You can create one at the [Vonage API Dashboard](https://dashboard.vonage.com/applications). + +- **Create an Application in the Dashboard** + + Once logged in, navigate to the [Applications page](https://dashboard.vonage.com/applications) via the main dashboard menu: + +
        + Applications dashboard view + Applications dashboard +
        + + If you don’t already have an application, create a new one: + +
        + Create new app + Create app button +
        + + During the setup process, make sure to: + + - Provide a name for your application. + - Generate and download the public and private keys. + - Enable **Video** capabilities. + + Refer to the following image for visual guidance: + +
        + Configuring a new app + Create app form +
        +
        + +- **Environment Variables** + + In the root project directory, create the backend environment file by running: + + ``` bash + cp backend/.env.example backend/.env + ``` + + Then, open **backend/.env** and fill in the required configuration: + + - **VONAGE_APP_ID** – This is the ID of your Vonage application. You can find it on the [Applications page](https://dashboard.vonage.com/applications). + - **VONAGE_PRIVATE_KEY** – If you've already generated a private key, use that. Otherwise, use the key you downloaded when creating the app. + + Frontend feature flags and display settings are configured in [`vcrBuild.env.sh`](vcrBuild.env.sh). The defaults work out of the box — edit that file only when you need to customise behaviour. See [Environment Configuration](#environment-configuration) for the full list of available options. + +
        + +- **Start in Development Mode** + + ``` bash + yarn dev + ``` + + This starts both the backend server (port **3345**) and the frontend Vite dev server (port **5173**). You can now access the app at [http://localhost:5173](http://localhost:5173). + +## Storybook + +Storybook is available for developing and testing UI components in isolation. + +To run Storybook for the frontend: + +```bash +yarn storybook:frontend +``` + +This will start the Storybook dev server at [http://localhost:6006](http://localhost:6006). + +----- + +To run Storybook for the ui: + +```bash +yarn storybook:ui +``` + +This will start the Storybook dev server at [http://localhost:6007](http://localhost:6007). + +## UI Customization + +The app theme is configured through the root `designTokens.json` file. + +### Customize your theme + +1. Edit `designTokens.json` at the project root with your palette/theme values. +2. Sync theme artifacts: + +```bash +yarn sync:theme-tokens +``` + +This command always regenerates `designTokens.example.json`, syncs `libs/ui/src/theme/helpers/designTokens/designTokens.json` from root `designTokens.json` when present, creates root `designTokens.json` from defaults when missing, rebuilds the Tailwind plugin, and formats the generated plugin file. + +--- + +## Environment Configuration + +The app has two parts — a **backend** server and a **frontend** UI. The backend is configured through `backend/.env`. Frontend settings are configured through [`vcrBuild.env.sh`](vcrBuild.env.sh), which is the single place for all frontend configuration. + +Create the backend configuration file by running: + +```bash +cp backend/.env.example backend/.env +``` + +Then open it in a text editor and fill in the values described below. + +--- + +### Backend (`backend/.env`) + +Open `backend/.env` and configure the following variables. + +#### Video service provider + +Exactly one provider block must be configured. + +**Vonage Video API (default)** + +| Variable | Required | Description | +|----------|----------|-------------| +| `VIDEO_SERVICE_PROVIDER` | ✅ | Must be `vonage` | +| `VONAGE_APP_ID` | ✅ | Your Vonage application ID from the [dashboard](https://dashboard.vonage.com/applications) | +| `VONAGE_PRIVATE_KEY` | ✅ | Contents of the private key file downloaded when creating the application | + +```ini +VIDEO_SERVICE_PROVIDER='vonage' +VONAGE_APP_ID='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +VONAGE_PRIVATE_KEY='-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY-----' +``` + +**OpenTok (TokBox) SDK** + +| Variable | Required | Description | +|----------|----------|-------------| +| `VIDEO_SERVICE_PROVIDER` | ✅ | Must be `opentok` | +| `OT_API_KEY` | ✅ | Your OpenTok API key | +| `OT_API_SECRET` | ✅ | Your OpenTok API secret | + +```ini +VIDEO_SERVICE_PROVIDER='opentok' +OT_API_KEY='your-api-key' +OT_API_SECRET='your-api-secret' +``` + +#### Vonage Cloud Runtime (VCR) + +| Variable | Required | Description | +|----------|----------|-------------| +| `VCR_PORT` | ⚠️ VCR only | Port exposed by VCR (typically `3345`). **Do not set this locally** — its presence switches the app to VCR storage. | + +#### Jira feedback integration (optional) + +Enables the in-call issue reporting tool to file tickets directly into Jira. + +| Variable | Description | +|----------|-------------| +| `JIRA_URL` | Base URL of your Jira instance | +| `JIRA_API_URL` | Jira REST API base URL | +| `JIRA_TOKEN` | API token for authentication | +| `JIRA_PROJECT_KEY` | Target project key | +| `JIRA_COMPONENT_ID` | Default component ID for filed issues | +| `JIRA_iOS_COMPONENT_ID` | Component ID for iOS issues | +| `JIRA_ANDROID_COMPONENT_ID` | Component ID for Android issues | +| `JIRA_EPIC_LINK` | Epic link field value | +| `JIRA_EPIC_URL` | URL to the target epic | + +--- + +### Frontend + +Frontend settings control which features are visible, what language the app uses, and how the video room behaves by default. **All frontend configuration lives in a single file: [`vcrBuild.env.sh`](vcrBuild.env.sh).** + +This file is loaded automatically whenever the app is built or deployed. To change a setting, open [`vcrBuild.env.sh`](vcrBuild.env.sh), update the relevant `export` line, and restart or rebuild: + +```bash +# vcrBuild.env.sh +export ALLOW_CHAT=false +export DEFAULT_LAYOUT_MODE='grid' +export I18N_SUPPORTED_LANGUAGES='en|es' +``` + +> **Note:** After editing [`vcrBuild.env.sh`](vcrBuild.env.sh) you need to restart the app (`yarn dev`) or trigger a new build for the changes to take effect. + +#### Network + +| Variable | Default | Description | +|----------|---------|-------------| +| `API_URL` | `http://localhost:3345` (local) / `window.location.origin` (production) | URL of the backend API server | +| `TUNNEL_DOMAIN` | — | ngrok (or similar) domain used when testing across devices. See [Testing on Multiple Devices](#testing-on-multiple-devices) | + +#### Internationalisation + +| Variable | Default | Accepted values | Description | +|----------|---------|-----------------|-------------| +| `I18N_FALLBACK_LANGUAGE` | `en` | `en` \| `en-US` \| `es` \| `es-MX` \| `it` | Language used when the user's locale is not supported | +| `I18N_SUPPORTED_LANGUAGES` | `en` | Pipe-separated list, e.g. `en\|es\|it` | Languages offered in the UI | + +#### Feature flags + +All feature flags are **boolean** (`true` / `false`). + +| Variable | Default | Description | +|----------|---------|-------------| +| `ALLOW_BACKGROUND_EFFECTS` | `true` | Enable virtual background and blur effects | +| `ALLOW_CAMERA_CONTROL` | `true` | Show the camera on/off toggle | +| `ALLOW_VIDEO_ON_JOIN` | `true` | Start with camera enabled when joining | +| `ALLOW_ADVANCED_NOISE_SUPPRESSION` | `true` | Enable the advanced noise-suppression toggle | +| `ALLOW_AUDIO_ON_JOIN` | `true` | Start with microphone enabled when joining | +| `ALLOW_MICROPHONE_CONTROL` | `true` | Show the microphone on/off toggle | +| `WAITING_ROOM_ALLOW_DEVICE_SELECTION` | `true` | Show device selectors in the waiting room | +| `MEETING_ROOM_ALLOW_DEVICE_SELECTION` | `true` | Show device selectors inside the meeting room | +| `ALLOW_ARCHIVING` | `true` | Enable meeting recording (archiving) | +| `ALLOW_CAPTIONS` | `true` | Enable live captions | +| `ALLOW_CHAT` | `true` | Enable the in-call group chat | +| `ALLOW_EMOJIS` | `true` | Enable emoji reactions | +| `ALLOW_SCREEN_SHARE` | `true` | Enable screen sharing | +| `SHOW_PARTICIPANT_LIST` | `true` | Show the participant list panel | +| `ENABLE_REPORT_ISSUE` | `false` | Show the in-call issue reporting tool | +| `BYPASS_WAITING_ROOM` | `false` | Skip the waiting room and join directly | +| `AVOID_FETCHING_APP_CONFIG` | `true` | Skip fetching remote app configuration on startup | + +#### Display defaults + +| Variable | Default | Accepted values | Description | +|----------|---------|-----------------|-------------| +| `DEFAULT_RESOLUTION` | `1280x720` | `1920x1080` \| `1280x960` \| `1280x720` \| `640x480` \| `640x360` \| `320x240` \| `320x180` | Default outgoing video resolution | +| `DEFAULT_LAYOUT_MODE` | `active-speaker` | `active-speaker` \| `grid` | Default in-room layout when a participant joins | +| `MIN_CUSTOM_VIDEO_BITRATE_BPS` | `5000` | Positive integer (bps) | Minimum selectable custom video bitrate in the Advanced Settings dialog | +| `MAX_CUSTOM_VIDEO_BITRATE_BPS` | `10000000` | Positive integer (bps) | Maximum selectable custom video bitrate in the Advanced Settings dialog | +| `SUPPORTED_FRAME_RATES` | `30\|15\|7\|1` | `\|`-separated positive integers (fps) | Frame rate options shown in the Advanced Settings video tab | + +> **Note:** `DEFAULT_LAYOUT_MODE` and `ALLOW_AUDIO_ON_JOIN` / `ALLOW_VIDEO_ON_JOIN` require the participant to **rejoin the room** to take effect after being changed. + +--- + +## Testing on Multiple Devices + +To test the video API across multiple devices on your local network, you can use **ngrok** to expose your frontend and backend publicly. + +1. Create an account at [ngrok](https://dashboard.ngrok.com/signup) if you haven't already. + +2. Follow the [Setup and Installation instructions](https://dashboard.ngrok.com/get-started/setup/) for your operating system to install and configure ngrok. + +3. **Start the application locally first:** + + ``` bash + yarn dev + ``` + + Make sure both the backend server (port 3345) and frontend dev server (port 5173) are running before proceeding to the next step. + +4. Create secure tunnels for both frontend and backend: + + **Set up ngrok configuration:** + + First, find your ngrok config file location: + ``` bash + ngrok config check + ``` + + Create or edit the ngrok configuration file (typically located at `~/Library/Application Support/ngrok/ngrok.yml` on macOS; `~/.config/ngrok/ngrok.yml` on Linux and `%HOMEPATH%\AppData\Local\ngrok\ngrok.yml` on Windows) with the following content: + + ``` yaml + version: "2" + tunnels: + frontend: + addr: 5173 + proto: http + backend: + addr: 3345 + proto: http + ``` + + **Start both tunnels:** + ``` bash + ngrok start backend frontend + ``` + + This command will create publicly accessible HTTPS URLs for both your frontend and backend. The output will appear in your terminal, similar to the image below: + +
        + ngrok output example + ngrok tunnel example +
        + +
        + +5. Copy the domains from both outputs and update [`vcrBuild.env.sh`](vcrBuild.env.sh): + + ``` bash + export TUNNEL_DOMAIN=your-frontend-domain.ngrok.io + export API_URL=https://your-backend-domain.ngrok.io + ``` + + **Note:** ngrok assigns temporary domains. You'll need to update these values each time the domains change. + +
        + +6. Open the provided frontend **Forwarding** URL in your browser. This exposes your entire application publicly, allowing devices on any network to access it. + +
        + +Enjoy testing! + +## Deployment to Vonage Cloud Runtime + +You can deploy the application to Vonage Cloud Runtime (VCR) for testing in a cloud environment. See the [VCR overview](https://developer.vonage.com/en/vonage-cloud-runtime/overview) for more information. + +For quick development deployments directly from your local machine, you can use the `vcr:dev` script: + +1. **Install the VCR CLI** (if not already installed): + + Follow the installation instructions at https://developer.vonage.com/en/vonage-cloud-runtime/getting-started/working-locally#cli-installation + +2. **Configure VCR with your credentials**: + + ```bash + vcr configure + ``` + + Enter your Vonage API Key and Secret, and select a region. + +3. **Generate application keys**: + + ```bash + vcr app generate-keys --app-id --region + ``` + + Replace `` with your Vonage application ID and `` with your region. + + > ⚠️ **Warning**: You should use a **separate** Vonage application for VCR deployment (different from the `VONAGE_APP_ID` in your `backend/.env` file) to avoid issues with your private key. + +4. **Set up your development configuration**: + + Copy the development configuration example file: + + ```bash + cp vcr.yml.example vcr-dev.yml + ``` + + Open `vcr-dev.yml` and add your application ID. + +5. **Deploy to development**: + + ```bash + yarn vcr:dev + ``` + +This will deploy using your local development configuration and code, making it quick to test changes in a cloud environment. + +## Testing + +### Integration Tests + +We have integration tests using [Playwright](https://playwright.dev/). We recommend using their [VSCode integration](https://playwright.dev/docs/getting-started-vscode) to run tests. + +To run the tests you need to run the app server separately: + +```console +# In one terminal tab +yarn start + +# In a separate tab. Or use vscode extension to run tests +yarn test:integration +``` + +#### Screenshot tests or Visual comparisons + +We use [Playwright Visual Comparison](https://playwright.dev/docs/test-snapshots) for Screenshot UI tests. Since screenshot tests are part of our integration tests, running our integration tests also executes the screenshot tests. + +#### Generating and Updating Screenshots + +If we need to update the expected screenshot due to UI changes, we can delete the existing expected screenshot and then run the test. The test will fail, but a new expected screenshot will be generated. Running the test again should pass, as the expected and actual screenshots will now match. + +For CI tests, we require screenshots for various browsers and operating systems because they [render interfaces with subtle differences](https://github.com/microsoft/playwright/issues/18240#issuecomment-1287546463). + +To capture CI-specific screenshots, you can use the `update-screenshots` job. This job is triggered by creating a pull request (PR) with the `update-screenshots` label on GitHub. Once triggered, it will capture new screenshots on the virtual machine (VM) and automatically push those to the PR's branch. + +### Running the backend and frontend test suites + +- To run the frontend and backend tests: +```console +yarn test +``` + +### Backend Suite + +- To run backend tests once: + +```console +yarn test:backend +``` + +- For additional CLI options, see [jest docs](https://jestjs.io/docs/cli). + +### Frontend Suite +We have frontend tests using [vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). We recommend using the [vitest VSCode integration]( https://marketplace.visualstudio.com/items?itemName=vitest.explorer) to run tests. + +For guidance on writing unit tests, see the [Test Instructions](./.github/instructions/test-files.instructions.md). + +Alternatively you can run the tests in the terminal: +- To run frontend tests once: + +```console +yarn test:frontend +``` + +- For additional CLI options, see [vitest docs](https://vitest.dev/guide/cli#options) + +## Code style + +### Linting and auto-formatting +We use eslint and prettier to format code. Prettier issues will show up in eslint too via eslint-plugin-prettier. +You can setup an eslint extension for your editor. For VSCode use: [dbaeumer.vscode-eslint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). + +You can either set up vscode to fix eslint issues on save or fix them with VSCode keyboard command palette shortcut `cmd + shift + p > "ESLint: Fix all auto-fixable problems"`. + +In the terminal you can run + +```console +yarn lint +``` + +to check for eslint issues and + +```console +yarn lint:fix +``` + +to fix any auto-fixable issues and also run prettier on all files. + +### File names + +All filenames are in `camelCase`. + +## Documentation Generation + +We use `typedoc` to generate documentation from our jsdoc comments. +Generated documents can be found in the `frontend/dist` folder. + +To generate documentation, run the following in the terminal + +```console +yarn nx run frontend:docs +``` + +## Code of Conduct + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Maintainers + +This repository is actively maintained by the Vonage Video team. + +For maintainer responsibilities, review expectations, and project ownership guidelines, see [MAINTAINERS.md](./MAINTAINERS.md). + +## Getting Involved + +If you wish to contribute to this project, read how in [Contributing](./docs/CONTRIBUTING.md). + +## Known Issues + +We track known issues in [Known Issues](./docs/KNOWN_ISSUES.md). Please refer to it for details. + +## Report Issues + +If you have any issues, feel free to open an issue or reach out to support via [support@api.vonage.com](support@api.vonage.com). diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/.env.example b/tutorials/vonage_video_ios_app-theme-customization/project/backend/.env.example new file mode 100644 index 00000000..cf5a9694 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/.env.example @@ -0,0 +1,44 @@ +# Get your Vonage Application ID and Private Key here: https://dashboard.nexmo.com/ +# If using OpenTok SDK and credentials make sure to remove this block and uncomment the block below with OT_ variables. + +VIDEO_SERVICE_PROVIDER='vonage' +VONAGE_APP_ID='' +# e.g. (this is an example application Id): +# VONAGE_APP_ID='12d0d0-aaaa-bbb-ccc-26b780fb' +VONAGE_PRIVATE_KEY='' +# e.g.: (this is an example private key) +# VONAGE_PRIVATE_KEY='----BEGIN RSA PRIVATE KEY----- +# MIIBOAIBAAJARsF2wfXtjllRR8nnz8+CLULn0bqgZtYktJB2BdcB5bw6OYmmDVCc +# TeTC3VXZATdSqNA6WDWCkSVinC05uYEOEwIDAQABAkArUAaYmSkAeKCO54Pl7Ert +# 1gT+l9XU3cW+Wqhdfnsjkdjaksdjas9phpdiQXNosI60a8YyeyBUjCtQGFwbJ1Kl +# AiEAioOWu1s5nbB6ioOXdhbW4Ovfdkjnldfjlkndfjk/njsCIQCCxRfwRVfDcC0h +# hvuOpFzvZ870deo1/OD8j4U8jG+aCQIgXeU55qO+eODLEN6Ha+urmikc1kyQC/KP +# aKMjV5PzfUUCIdfgdfgdERJ1K9EVwfE/5bH1E+TERb3j21UZZphjGv15AiBBs0w5 +# WRuPspPXIAHPKrjEHkUsgDZHW/V0fJWbIjJarw==6ISP6wuV0E6SRl1xKVyoYT0D +# 8iIsCNO9GfFOx3HWN7Q1++fAAhV37 +# -----END PRIVATE KEY----- +# ' + +# If using OpenTok SDK (https://tokbox.com/developer/sdks/js/) remove the variables above and use these instead. +# VIDEO_SERVICE_PROVIDER='opentok' +# OT_API_KEY=api-key +# OT_API_SECRET=api-secret + +# Secret used to sign and verify session key JWTs. +SESSION_KEY_SECRET='' + +# If deploying to Vonage Cloud Runtime (https://developer.vonage.com/en/vonage-cloud-runtime/overview) +# enter your port here; the default is 3345. +# DO NOT SET THIS VARIABLE IN LOCAL ENV: +# If this variable is set the app will try to use VCR Storage. +# VCR_PORT=3345 + +# If implementing the jira service for customer feedback, enter your credentials here. +# JIRA_URL=jira-url +# JIRA_API_URL=jira-api-url +# JIRA_TOKEN=jira-token +# JIRA_PROJECT_KEY=jira-project-key +# JIRA_COMPONENT_ID=jira-component-id +# JIRA_EPIC_URL='jira-epic-url' +# JIRA_EPIC_LINK='jira-epic-link' +# JIRA_SEVERITY_ID='jira-severity-id' diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/eslint.config.mjs b/tutorials/vonage_video_ios_app-theme-customization/project/backend/eslint.config.mjs new file mode 100644 index 00000000..6fce3e0d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/eslint.config.mjs @@ -0,0 +1,12 @@ +import globals from 'globals'; +import baseConfig from '../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.{ts,tsx,js,jsx}'], + languageOptions: { + globals: globals.node, + }, + }, +]; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/__mocks__/config.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/__mocks__/config.ts new file mode 100644 index 00000000..1def333a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/__mocks__/config.ts @@ -0,0 +1,18 @@ +import { jest } from '@jest/globals'; + +const mockOpentokConfig = () => { + return { + default: jest.fn().mockImplementation(() => { + return { + apiKey: 'test-api-key', + apiSecret: 'test-api-secret', + applicationId: 'test-application-id', + privateKey: 'test-private-key', + provider: 'opentok', + gollumUrl: 'https://example.com', + loggerVerbose: false, + }; + }), + }; +}; +export default mockOpentokConfig; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/blockCallsForArgs.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/blockCallsForArgs.ts new file mode 100644 index 00000000..55dbdd26 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/blockCallsForArgs.ts @@ -0,0 +1,26 @@ +type PromiseMap = { + [key: string]: PromiseWithResolvers; +}; + +/** + * Util to block simultaneous calls to a function until the first call resolves + * @param {Function} fn - function to be blocked, first argument must be a string key + * @returns {Function} wrapped function + */ +const blockCallsForArgs = (fn: (key: string, ...args: unknown[]) => T) => { + const callsInProgress: PromiseMap = {}; + return async (key: string, ...args: unknown[]): Promise> => { + if (callsInProgress[key]) { + await callsInProgress[key].promise; + } else { + const promiseWithResolvers = Promise.withResolvers(); + callsInProgress[key] = promiseWithResolvers; + } + const res = await fn(key, ...args); + callsInProgress[key]?.resolve(null); + delete callsInProgress[key]; + return res; + }; +}; + +export default blockCallsForArgs; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/config.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/config.ts new file mode 100644 index 00000000..610a8ae1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/config.ts @@ -0,0 +1,80 @@ +import dotenv from 'dotenv'; +import path from 'node:path'; +import { Config, FeedbackConfig } from '../types/config'; +import { fileURLToPath } from 'node:url'; + +/** + * The runtimeDirectory works different on CJS and ESM + * We are embedding __IS_CJS__ variable during build time enforce the correct behavior + */ +let runtimeDir: string = ''; +if (process.env.__IS_CJS__) { + runtimeDir = __dirname; +} else { + runtimeDir = path.dirname(fileURLToPath(import.meta.url)); +} + +dotenv.config({ path: path.join(runtimeDir, '.env') }); + +const loadConfig = (): Config => { + const provider = process.env.VIDEO_SERVICE_PROVIDER ?? ''; + const sessionKeySecret = process.env.SESSION_KEY_SECRET ?? ''; + + const loggerVerbose = process.env.LOGGER_VERBOSE === 'true'; + + const feedbackConfig: FeedbackConfig = { + url: process.env.JIRA_URL, + apiUrl: process.env.JIRA_API_URL, + token: process.env.JIRA_TOKEN, + key: process.env.JIRA_PROJECT_KEY, + componentId: process.env.JIRA_COMPONENT_ID, + iOSComponentId: process.env.JIRA_iOS_COMPONENT_ID, + androidComponentId: process.env.JIRA_ANDROID_COMPONENT_ID, + epicLink: process.env.JIRA_EPIC_LINK, + epicUrl: process.env.JIRA_EPIC_URL, + severityId: process.env.JIRA_SEVERITY_ID, + gollumUrl: process.env.GOLLUM_BASE_URL, + }; + + if (provider === 'vonage') { + const applicationId = process.env.VONAGE_APP_ID ?? ''; + const privateKey = process.env.VONAGE_PRIVATE_KEY ?? ''; + const videoHost = process.env.VONAGE_VIDEO_HOST; + + if (!applicationId || !privateKey) { + throw new Error('Missing config values for Vonage'); + } + + return { + ...feedbackConfig, + applicationId, + privateKey, + provider: 'vonage', + videoHost, + sessionKeySecret, + loggerVerbose, + }; + } + + if (provider === 'opentok') { + const apiKey = process.env.OT_API_KEY ?? ''; + const apiSecret = process.env.OT_API_SECRET ?? ''; + + if (!apiKey || !apiSecret) { + throw new Error('Missing config values for OpenTok'); + } + + return { + ...feedbackConfig, + apiKey, + apiSecret, + provider: 'opentok', + sessionKeySecret, + loggerVerbose, + }; + } + + throw new Error(`Unknown video service provider: ${provider || 'undefined'}`); +}; + +export default loadConfig; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/delay.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/delay.ts new file mode 100644 index 00000000..2951fba3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/delay.ts @@ -0,0 +1,12 @@ +/** + * Waits for a specified of time + * @param {number} delayMs - the number of milliseconds to wait + * @returns {Promise} a promise that resolves after the delay has elapsed + */ +const delay = (delayMs: number): Promise => + new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delayMs); + }); +export default delay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getOrCreateSessionKeyFromRoomName.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getOrCreateSessionKeyFromRoomName.ts new file mode 100644 index 00000000..7c5573fb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getOrCreateSessionKeyFromRoomName.ts @@ -0,0 +1,37 @@ +import { VideoClient } from '../routes/video'; +import getSessionStorageService from '../sessionStorageService'; +import blockCallsForArgs from './blockCallsForArgs'; +import { isValidRoomName } from '@common/assertions'; +import { makeBadRequestErrorHandler } from '@api-lib/errors'; + +const sessionService = getSessionStorageService(); + +const getOrCreateSessionKeyFromRoomName = async ({ + videoClient, + roomName, +}: { + roomName: string; + videoClient: VideoClient; +}) => { + return blockCallsForArgs(async () => { + const storedSessionKey = await sessionService.getSessionKeyByRoomName({ roomName }); + if (storedSessionKey) return storedSessionKey; + + if (!isValidRoomName(roomName)) { + throw makeBadRequestErrorHandler(`Invalid room name: ${roomName}.`)(null); + } + + const session = await videoClient.createSession({ roomName }); + const { sessionKey, sessionId } = session; + + await sessionService.setSession({ + roomName, + sessionKey, + sessionId, + }); + + return session.sessionKey; + })(roomName); +}; + +export default getOrCreateSessionKeyFromRoomName; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getSessionKeyFromRoomName.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getSessionKeyFromRoomName.ts new file mode 100644 index 00000000..19ecf5d6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/getSessionKeyFromRoomName.ts @@ -0,0 +1,16 @@ +import { makeNotFoundErrorHandler } from '@api-lib/errors'; +import getSessionStorageService from '../sessionStorageService'; + +const sessionService = getSessionStorageService(); + +const getSessionKeyFromRoomName = async ({ roomName }: { roomName: string }): Promise => { + const storedSessionKey = await sessionService.getSessionKeyByRoomName({ roomName }); + + if (!storedSessionKey) { + throw makeNotFoundErrorHandler(`No session found for room name: ${roomName}`)(null); + } + + return storedSessionKey; +}; + +export default getSessionKeyFromRoomName; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/index.ts new file mode 100644 index 00000000..6a3e710f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/helpers/index.ts @@ -0,0 +1,4 @@ +export { default as blockCallsForArgs } from './blockCallsForArgs'; +export { default as loadConfig } from './config'; +export { default as getOrCreateSessionKeyFromRoomName } from './getOrCreateSessionKeyFromRoomName'; +export { default as getSessionKeyFromRoomName } from './getSessionKeyFromRoomName'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/index.ts new file mode 100644 index 00000000..3f538bc5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/index.ts @@ -0,0 +1,3 @@ +import startServer from './server'; + +void startServer(); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest.config.js b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest.config.js new file mode 100644 index 00000000..3dc769cb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest.config.js @@ -0,0 +1,41 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + clearMocks: true, + moduleDirectories: ['ts', 'tsx', 'node_modules'], + coverageProvider: 'v8', + roots: ['/'], + modulePaths: [''], + testMatch: ['**/tests/**/*.+(ts|tsx)', '**/?(*.)+(test).+(ts|tsx)'], + setupFiles: ['/jest/documentPolyfill.js', '/jest/setEnvVars.js'], + coveragePathIgnorePatterns: ['/node_modules/', '/tests/', '/index\\.ts$'], + collectCoverageFrom: [ + '**/*.{ts,tsx}', + '!**/node_modules/**', + '!**/tests/**', + '!**/index.ts', + + // interfaces/types files, don't need to be tested + '!**/storage/sessionStorage.ts', + ], + moduleNameMapper: { + '^@api-lib$': '/../libs/api/src', + '^@api-lib/(.*)$': '/../libs/api/src/$1', + '^@common$': '/../libs/common/src', + '^@common/(.*)$': '/../libs/common/src/$1', + '^@common-test$': '/../libs/common/test', + '^@common-test/(.*)$': '/../libs/common/test/$1', + '^@node$': '/../libs/common/srcNode', + '^@node/(.*)$': '/../libs/common/srcNode/$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + useESM: true, + }, + ], + }, +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.js b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.js new file mode 100644 index 00000000..c8e22a86 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.js @@ -0,0 +1,15 @@ +/** + * Polyfill for document.cookie required by opentok-solutions-logging in Node.js test environment. + * Must run before any module that imports opentok-solutions-logging is loaded. + */ +if (typeof globalThis.document === 'undefined') { + let cookieStore = ''; + globalThis.document = { + get cookie() { + return cookieStore; + }, + set cookie(val) { + cookieStore = val; + }, + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.test.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.test.ts new file mode 100644 index 00000000..c6a75e45 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/documentPolyfill.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; + +const doc = (globalThis as unknown as { document: { cookie: string } }).document; + +/** + * Tests for documentPolyfill.js. + * The polyfill runs as a Jest setup file before these tests, so document is already defined. + */ +describe('documentPolyfill', () => { + it('defines globalThis.document when missing', () => { + expect(doc).toBeDefined(); + expect(typeof doc).toBe('object'); + }); + + it('provides document.cookie getter that returns a string', () => { + expect(doc.cookie).toBeDefined(); + expect(typeof doc.cookie).toBe('string'); + }); + + it('allows setting and reading document.cookie', () => { + const testValue = 'test=value; path=/'; + doc.cookie = testValue; + expect(doc.cookie).toBe(testValue); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.js b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.js new file mode 100644 index 00000000..eb30685f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.js @@ -0,0 +1,14 @@ +process.env.JIRA_API_URL = 'https://example.com'; +process.env.JIRA_URL = 'https://example.com'; +process.env.JIRA_TOKEN = 'JIRA'; +process.env.JIRA_COMPONENT_ID = 'componentId'; +process.env.JIRA_iOS_COMPONENT_ID = 'iOSComponentId'; +process.env.JIRA_ANDROID_COMPONENT_ID = 'androidComponentId'; +process.env.JIRA_EPIC_LINK = 'jiraEpicLink'; +process.env.JIRA_EPIC_URL = 'jiraEpicUrl'; +process.env.VONAGE_APP_ID = 'vonageAppId'; +process.env.VONAGE_PRIVATE_KEY = 'vonagePrivateKey'; +process.env.OT_API_KEY = 'someOpenTokKey'; +process.env.OT_API_SECRET = 'opentokAPISecret'; +process.env.SESSION_KEY_SECRET = 'test-session-key-secret-for-jwt-signing'; +process.env.GOLLUM_BASE_URL = 'https://example.com'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.test.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.test.ts new file mode 100644 index 00000000..f2f0c418 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/jest/setEnvVars.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from '@jest/globals'; + +describe('Environment Variables', () => { + test('should have the correct JIRA_API_URL', () => { + const jiraApiUrl = 'https://example.com'; + expect(process.env.JIRA_API_URL).toBe(jiraApiUrl); + }); + + test('should have the correct JIRA_URL', () => { + const jiraUrl = 'https://example.com'; + expect(process.env.JIRA_URL).toBe(jiraUrl); + }); + + test('should have the correct JIRA_TOKEN', () => { + const jiraToken = 'JIRA'; + expect(process.env.JIRA_TOKEN).toBe(jiraToken); + }); + + test('should have the correct JIRA_COMPONENT_ID', () => { + const jiraComponentId = 'componentId'; + expect(process.env.JIRA_COMPONENT_ID).toBe(jiraComponentId); + }); + + test('should have the correct JIRA_iOS_COMPONENT_ID', () => { + const jiraComponentId = 'iOSComponentId'; + expect(process.env.JIRA_iOS_COMPONENT_ID).toBe(jiraComponentId); + }); + + test('should have the correct JIRA_ANDROID_COMPONENT_ID', () => { + const jiraComponentId = 'androidComponentId'; + expect(process.env.JIRA_ANDROID_COMPONENT_ID).toBe(jiraComponentId); + }); + + test('should have the correct JIRA_EPIC_LINK', () => { + const jiraEpicLink = 'jiraEpicLink'; + expect(process.env.JIRA_EPIC_LINK).toBe(jiraEpicLink); + }); + + test('should have the correct JIRA_EPIC_URL', () => { + const jiraEpicUrl = 'jiraEpicUrl'; + expect(process.env.JIRA_EPIC_URL).toBe(jiraEpicUrl); + }); + + test('should have the correct VONAGE_APP_ID', () => { + const vonageAppId = 'vonageAppId'; + expect(process.env.VONAGE_APP_ID).toBe(vonageAppId); + }); + + test('should have the correct VONAGE_PRIVATE_KEY', () => { + const vonagePrivateKey = 'vonagePrivateKey'; + expect(process.env.VONAGE_PRIVATE_KEY).toBe(vonagePrivateKey); + }); + + test('should have the correct OT_API_KEY', () => { + const openTokAPIKey = 'someOpenTokKey'; + expect(process.env.OT_API_KEY).toBe(openTokAPIKey); + }); + + test('should have the correct OT_API_SECRET', () => { + const openTokAPISecret = 'opentokAPISecret'; + expect(process.env.OT_API_SECRET).toBe(openTokAPISecret); + }); + + test('should have the correct GOLLUM_BASE_URL', () => { + const gollumBaseUrl = 'https://example.com'; + expect(process.env.GOLLUM_BASE_URL).toBe(gollumBaseUrl); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/errorHandler.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/errorHandler.ts new file mode 100644 index 00000000..2742e0d6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/errorHandler.ts @@ -0,0 +1,84 @@ +import type { Request, Response, NextFunction } from 'express'; +import StatusCodeEnum from 'status-code-enum'; +import { isApplicationError } from '@common/errors/assertions'; +import ApplicationServerError from '@api-lib/errors/ApplicationServerError'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +function tryToLogApplicationError(error: unknown): void { + try { + if (error instanceof ApplicationServerError) { + console.error('[ApplicationServerError]', error.retrieveErrorLogDetails()); + } else if (isApplicationError(error)) { + console.error('[ApplicationError]', error.message); + } else { + console.error('[Error]', error); + } + } catch { + console.error('[Error] (log failed)', error); + } +} + +/** + * Application-level error handler. Handles ValidationError with structured issues, + * and all other errors via ApplicationServerError with safe export. + */ +export function errorHandler( + error: unknown, + req: Request, + res: Response, + _next: NextFunction +): void { + if (res.headersSent) return; + + tryToLogApplicationError(error); + + if (isDevelopment) { + console.error('Development error log:', error); + } + + const applicationError = new ApplicationServerError({ + src: error, + // If the error is not an ApplicationError, fallback to internal server error + fallbackConfig: { + fallbackMessage: 'Internal server error', + statusCode: StatusCodeEnum.ServerErrorInternal, + }, + }); + + // avoids exposing sensitive information while still providing useful error information + const safeError = applicationError.exportSafely(); + const { statusCode } = safeError; + + const accepts = req.headers.accept ?? ''; + + const isJsonRequest = + accepts.includes('application/json') || + req.xhr || + req.headers?.['content-type']?.includes('application/json'); + + if (isJsonRequest) { + res.status(safeError.statusCode).json(safeError); + return; + } + + const isHtmlRequest = accepts.includes('text/html'); + + if (isHtmlRequest) { + const safeMessage = safeError.message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + res + .status(statusCode) + .send(`

        ${statusCode}

        ${safeMessage}

        `); + + return; + } + + // fallback to plain text response for other request types + res.status(statusCode).send(safeError.message); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.test.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.test.ts new file mode 100644 index 00000000..60834149 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.test.ts @@ -0,0 +1,70 @@ +import type { Request, Response } from 'express'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +const helmetHandlerMock = jest.fn(); + +const helmetMock = jest.fn(() => helmetHandlerMock) as jest.Mock & { + contentSecurityPolicy: { + getDefaultDirectives: jest.Mock; + }; +}; + +helmetMock.contentSecurityPolicy = { + getDefaultDirectives: jest.fn(() => ({ + 'default-src': ["'self'"], + })), +}; + +jest.unstable_mockModule('helmet', () => ({ + default: helmetMock, +})); + +describe('helmetMiddleware', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env.NODE_ENV = 'production'; + }); + + it('should call the helmet handler', async () => { + const { default: helmetMiddleware } = await import('./helmetMiddleware'); + + const { req, res, next } = createRequestParameters(); + + helmetMiddleware(req, res, next); + + expect(helmetHandlerMock).toHaveBeenCalledWith(req, res, next); + }); + + it('should disable contentSecurityPolicy in development', async () => { + process.env.NODE_ENV = 'development'; + + const { default: helmetMiddleware } = await import('./helmetMiddleware'); + + const { req, res, next } = createRequestParameters(); + + helmetMiddleware(req, res, next); + + expect(helmetMock).toHaveBeenCalledWith( + expect.objectContaining({ + contentSecurityPolicy: false, + hsts: false, + }) + ); + + expect(helmetHandlerMock).toHaveBeenCalledWith(req, res, next); + }); +}); + +function createRequestParameters() { + const req = {} as Request; + + const res = { + setHeader: jest.fn(), + removeHeader: jest.fn(), + } as unknown as Response; + + const next = jest.fn(); + + return { req, res, next }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.ts b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.ts new file mode 100644 index 00000000..58076791 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/backend/middleware/helmetMiddleware/helmetMiddleware.ts @@ -0,0 +1,82 @@ +import helmet from 'helmet'; +import { RequestHandler } from 'express'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +const helmetHandler = helmet({ + contentSecurityPolicy: isDevelopment + ? false + : { + directives: { + // Allow scripts from self and Vonage/OpenTok static assets. + // Needed by the Vonage/OpenTok SDK to load MediaPipe transformer assets, + // including task-vision.js and worker-side scripts loaded through importScripts(...). + 'script-src': [ + "'self'", + // required for web assembly + "'unsafe-eval'", + 'blob:', + 'data:', + 'https://static.opentok.com', + ], + + // Allow connections to self and Vonage/OpenTok backend services. + // Needed for signaling, logging, REST calls, and WebSocket traffic. + 'connect-src': [ + "'self'", + 'https://*.opentok.com', + 'https://*.tokbox.com', + 'https://*.vonage.com', + 'wss://*.opentok.com', + 'wss://*.tokbox.com', + 'wss://*.vonage.com', + 'https://static.opentok.com', + ], + + // Allow images from self, inline/base64 images, blob URLs, and any HTTPS image source. + 'img-src': ["'self'", 'data:', 'blob:', 'https:'], + + // Allow workers created by the SDK/app, including blob-based and inline data: workers. + // Also allow Vonage/OpenTok static assets because MediaPipe may load worker-side + // scripts from static.opentok.com. + 'worker-src': ["'self'", 'blob:', 'data:', 'https://static.opentok.com'], + + // Allow media resources from self, blob URLs, and HTTPS sources. + 'media-src': ["'self'", 'blob:', 'https:'], + }, + }, + + // Disabled so this page can consume external resources without requiring CORP/COEP headers. + // This helps avoid breaking third-party fonts, CDNs, workers, and integrations. + crossOriginEmbedderPolicy: false, + + // Allow external sites to load resources served by this app. + // Needed when the VeraRoom web component/assets are embedded on third-party sites. + crossOriginResourcePolicy: { policy: 'cross-origin' }, + + // Prevent clickjacking by allowing this page to be framed only by the same origin. + // This does not affect usage as a custom element via + + diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/package.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/package.json new file mode 100644 index 00000000..5b1f88af --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/package.json @@ -0,0 +1,7 @@ +{ + "name": "frontend", + "version": "1.5.0", + "private": true, + "description": "React reference application for the Vonage Video web client SDK.", + "type": "module" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/project.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/project.json new file mode 100644 index 00000000..10f2b219 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/project.json @@ -0,0 +1,134 @@ +{ + "$schema": "../node_modules/nx/schemas/project-schema.json", + "name": "frontend", + "projectType": "application", + "root": "frontend", + "sourceRoot": "frontend/src", + "targets": { + "build": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/dist"], + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && vite build && nx run frontend:cp-build'" + } + }, + "build-room": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/distRoom"], + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && BUILD_TARGET=vcr vite build --config src/VeraRoom/vite.veraroom.config.ts'" + }, + "configurations": { + "standalone": { + "command": "bash -c 'source ../vcrBuild.env.sh && BUILD_TARGET=standalone vite build --config src/VeraRoom/vite.veraroom.config.ts && find distRoom/assets -type f ! -name \"*.js\" ! -name \"*.mp3\" -delete'" + } + } + }, + "build-room-example": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/distRoom"], + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && vite build --config src/VeraRoom/example/vite.example.config.ts'" + } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && concurrently \"nx run frontend:ts-check:watch\" \"vite --host\"'" + } + }, + "serve": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && vite --host'" + } + }, + "preview": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && vite preview'" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "eslint 'frontend/src/**/*.{ts,tsx}' --config frontend/eslint.config.mjs --report-unused-disable-directives --max-warnings 0" + } + }, + "lint:fix": { + "executor": "nx:run-commands", + "options": { + "command": "eslint 'frontend/src/**/*.{ts,tsx}' --config frontend/eslint.config.mjs --fix --report-unused-disable-directives --max-warnings 0" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && vitest run'" + }, + "configurations": { + "coverage": { + "command": "bash -c 'source ../vcrBuild.env.sh && vitest run --coverage'" + } + } + }, + "docs": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "typedoc" + } + }, + "docs:watch": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "typedoc --watch" + } + }, + "ts-check": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "tsc -p tsconfig.json --pretty true --noEmit" + } + }, + "ts-check:watch": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "tsc -p tsconfig.json --noEmit --watch --preserveWatchOutput" + } + }, + "cp-build": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "mkdir -p ../backend/dist/dist && cp -R dist/. ../backend/dist/dist/" + } + }, + "storybook": { + "executor": "nx:run-commands", + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && storybook dev -p 6006'" + } + }, + "build-storybook": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/storybook-static"], + "options": { + "cwd": "frontend", + "command": "bash -c 'source ../vcrBuild.env.sh && storybook build'" + } + } + }, + "tags": [] +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/assets/sound.mp3 b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/assets/sound.mp3 new file mode 100644 index 00000000..ab940459 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/assets/sound.mp3 differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/bookshelf-room.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/bookshelf-room.jpg new file mode 100644 index 00000000..b6d7fb56 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/bookshelf-room.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/busy-room.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/busy-room.jpg new file mode 100644 index 00000000..c8f06bd3 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/busy-room.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/dune-view.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/dune-view.jpg new file mode 100644 index 00000000..39c4a2e4 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/dune-view.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/hogwarts.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/hogwarts.jpg new file mode 100644 index 00000000..c7d71d64 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/hogwarts.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/library.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/library.jpg new file mode 100644 index 00000000..1b033ad7 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/library.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/new-york.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/new-york.jpg new file mode 100644 index 00000000..3cb2ec7f Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/new-york.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/plane.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/plane.jpg new file mode 100644 index 00000000..ad375347 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/plane.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/white-room.jpg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/white-room.jpg new file mode 100644 index 00000000..4e507c2d Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/background/white-room.jpg differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-pending.png b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-pending.png new file mode 100644 index 00000000..ac42c9e4 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-pending.png differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-rejected.png b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-rejected.png new file mode 100644 index 00000000..f1014876 Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/access-dialog-rejected.png differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/favicon.ico b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/favicon.ico new file mode 100644 index 00000000..1da0b49a Binary files /dev/null and b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/favicon.ico differ diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-desktop.svg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-desktop.svg new file mode 100644 index 00000000..cc9f7a8c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-desktop.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-mobile.svg b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-mobile.svg new file mode 100644 index 00000000..c56412cf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/public/images/vonage-logo-mobile.svg @@ -0,0 +1 @@ +Asset 4 \ No newline at end of file diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.spec.tsx new file mode 100644 index 00000000..d87edc22 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { PropsWithChildren } from 'react'; +import App from './App'; + +// Mock the page components to make text assertions easy +vi.mock('./pages/LandingPage', () => ({ default: () =>
        Landing Page
        })); +vi.mock('./pages/GoodBye/index', () => ({ default: () =>
        GoodBye Page
        })); +vi.mock('./pages/UnsupportedBrowserPage', () => ({ + default: () =>
        Unsupported Browser
        , +})); + +vi.mock('./components/RedirectToWaitingRoom', () => ({ + default: ({ children }: PropsWithChildren) => children, +})); + +describe('App routing', () => { + it('renders LandingPage on unknown route', () => { + window.history.pushState({}, '', '/unknown'); + render(); + expect(screen.getByText(/Landing Page/i)).toBeInTheDocument(); + }); + + it('renders GoodBye page on /goodbye', () => { + window.history.pushState({}, '', '/goodbye'); + render(); + expect(screen.getByText(/GoodBye Page/i)).toBeInTheDocument(); + }); + + it('renders UnsupportedBrowserPage on /unsupported-browser', () => { + window.history.pushState({}, '', '/unsupported-browser'); + render(); + expect(screen.getByText(/Unsupported Browser/i)).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.tsx new file mode 100644 index 00000000..d723e62e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/App.tsx @@ -0,0 +1,100 @@ +import { BrowserRouter as Router, Route, Routes, FutureConfig } from 'react-router-dom'; +import './css/App.css'; +import './css/index.css'; +import MeetingRoom from './pages/MeetingRoom'; +import GoodBye from './pages/GoodBye/index'; +import WaitingRoom from './pages/WaitingRoom'; +import { PreviewPublisherProvider } from './Context/PreviewPublisherProvider'; +import LandingPage from './pages/LandingPage'; +import { PublisherProvider } from './Context/PublisherProvider'; +import { RedirectToWaitingRoom, ErrorBoundary, EnvGuard } from './components'; +import UnsupportedBrowserPage from './pages/UnsupportedBrowserPage'; +import RoomProvider from './Context/RoomProvider'; +import AppContextProvider from './AppContextProvider'; +import RedirectToUnsupportedBrowserPage from '@components/RedirectToUnsupportedBrowserPage'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import WaitingRoomSkeleton from '@pages/WaitingRoom/WaitingRoom.skeleton'; +import MeetingRoomSkeleton from '@pages/MeetingRoom/MeetingRoom.skeleton'; +import SessionProvider from '@Context/SessionProvider/session'; +import LoggerSynchronizer from '@Context/LoggerSynchronizer'; +import { ErrorPage } from './pages/ErrorBoundary'; +import { runtime$ } from '@core/stores'; +import { videoClient } from './services'; +import { NotificationsContainer } from '@ui/components'; +import { BackendLoggingProvider } from './logger/providers'; + +const futureConfig: Partial = { + /** + * Enable relative splat paths to ensure that dynamic imports in the app work correctly regardless of the base path. + */ + v7_relativeSplatPath: true, + v7_startTransition: true, +}; + +const InnerApp = () => { + return ( +
        + + + }> + }> + + + + + + + } + /> + + + }> + + + + + + + + + + + } + /> + + + } /> + } /> + } /> + + +
        + ); +}; + +export const loggerProvider = new BackendLoggingProvider(); + +/** + * The wrapper is necessary temporarily since app also need to have access to theme context. + */ +const App = () => { + return ( + + }> + + + + + + + + ); +}; + +export default App; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/AppContextProvider.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/AppContextProvider.tsx new file mode 100644 index 00000000..a189e8dc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/AppContextProvider.tsx @@ -0,0 +1,15 @@ +import React, { type PropsWithChildren } from 'react'; +import UserProvider from '@Context/user'; +import { ThemeProvider, type ThemeProviderProps } from '@ui/theme'; + +type AppContextProviderProps = PropsWithChildren; + +const AppContextProvider: React.FC = ({ children, container }) => { + return ( + + {children} + + ); +}; + +export default AppContextProvider; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/AdvancedSettings.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/AdvancedSettings.tsx new file mode 100644 index 00000000..6f1ad158 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/AdvancedSettings.tsx @@ -0,0 +1,185 @@ +import createGlobalState from 'react-global-state-hooks/createGlobalState'; +import actions from 'react-global-state-hooks/actions'; +import { z } from 'zod'; +import type { + AdvancedSettingsAudioBitrateMode, + AdvancedSettingsBitrateMode, + AdvancedSettingsCodecMode, + AdvancedSettingsCustomAudioBitrate, + AdvancedSettingsCustomVideoBitrate, + AdvancedSettingsFrameRate, + AdvancedSettingsManualCodecOrder, + AdvancedSettingsResolution, + AdvancedSettingsTab, +} from '@components/AdvancedSettingsDialog/types/types'; +import { + ADVANCED_SETTINGS_AUDIO_BITRATE_MODE, + ADVANCED_SETTINGS_BITRATE_MODE, + ADVANCED_SETTINGS_CODEC_MODE, +} from '@components/AdvancedSettingsDialog/types/types'; +import { env, RESOLUTIONS } from '../../env'; + +const INITIAL_STATE = { + isOpen: false, + selectedTab: 'general' as AdvancedSettingsTab, + bitrateMode: ADVANCED_SETTINGS_BITRATE_MODE.default as AdvancedSettingsBitrateMode, + customVideoBitrate: 500_000 as AdvancedSettingsCustomVideoBitrate, + codecMode: ADVANCED_SETTINGS_CODEC_MODE.automatic, + codecPriority: ['vp9', 'vp8', 'h264'] as AdvancedSettingsManualCodecOrder, + frameRate: 30 as AdvancedSettingsFrameRate, + resolution: env.DEFAULT_RESOLUTION ?? '1280x720', + audioBitrateMode: ADVANCED_SETTINGS_AUDIO_BITRATE_MODE.automatic, + customAudioBitrate: 128 as AdvancedSettingsCustomAudioBitrate, + enableDtx: true, + publisherAudioFallbackEnabled: false, + subscriberAudioFallbackEnabled: false, + publisherStatisticsEnabled: false, +}; + +export type advancedSettings = typeof INITIAL_STATE; + +const advancedSettingsSchema: z.ZodType = z.object({ + isOpen: z.boolean(), + selectedTab: z.enum(['general', 'video', 'audio', 'statistics']), + bitrateMode: z.enum(['default', 'bw_saver', 'extra_bw_saver', 'custom']), + customVideoBitrate: z + .number() + .int() + .min(env.MIN_CUSTOM_VIDEO_BITRATE_BPS) + .max(env.MAX_CUSTOM_VIDEO_BITRATE_BPS), + codecMode: z.enum(['automatic', 'manual']), + codecPriority: z.tuple([ + z.enum(['vp8', 'vp9', 'h264']), + z.enum(['vp8', 'vp9', 'h264']), + z.enum(['vp8', 'vp9', 'h264']), + ]), + frameRate: z.custom( + (value): value is AdvancedSettingsFrameRate => + typeof value === 'number' && + Number.isInteger(value) && + env.SUPPORTED_FRAME_RATES.includes(value), + { message: 'Unsupported frame rate' } + ), + resolution: z.enum(RESOLUTIONS), + audioBitrateMode: z.enum(['automatic', 'custom']), + customAudioBitrate: z.number().int().min(6).max(510), + enableDtx: z.boolean(), + publisherAudioFallbackEnabled: z.boolean(), + subscriberAudioFallbackEnabled: z.boolean(), + publisherStatisticsEnabled: z.boolean(), +}); + +const advancedSettings$ = createGlobalState(INITIAL_STATE, { + localStorage: { + key: 'advancedSettings', + selector: (state) => + ({ + ...state, + isOpen: false, + }) as advancedSettings, + + validator: ({ restored, initial }): advancedSettings => { + const restoredState = advancedSettingsSchema.safeParse(restored); + const fallbackState = initial as advancedSettings; + + if (restoredState.success) { + return restoredState.data; + } + + console.error('AdvancedSettings: invalid restored localStorage state', restoredState.error); + + return fallbackState; + }, + }, + actions: { + open() { + return () => { + partialUpdate({ isOpen: true }); + }; + }, + close() { + return () => { + partialUpdate({ isOpen: false }); + }; + }, + setSelectedTab(tab: AdvancedSettingsTab) { + return () => { + partialUpdate({ selectedTab: tab }); + }; + }, + setBitrateMode(value: AdvancedSettingsBitrateMode) { + return () => { + partialUpdate({ bitrateMode: value }); + }; + }, + setCustomVideoBitrate(value: AdvancedSettingsCustomVideoBitrate) { + return () => { + partialUpdate({ customVideoBitrate: value }); + }; + }, + setCodecMode(value: AdvancedSettingsCodecMode) { + return () => { + partialUpdate({ codecMode: value }); + }; + }, + setCodecPriority(value: AdvancedSettingsManualCodecOrder) { + return () => { + partialUpdate({ codecPriority: value }); + }; + }, + setFrameRate(value: AdvancedSettingsFrameRate) { + return () => { + partialUpdate({ frameRate: value }); + }; + }, + setResolution(value: AdvancedSettingsResolution) { + return () => { + partialUpdate({ resolution: value }); + }; + }, + setAudioBitrateMode(value: AdvancedSettingsAudioBitrateMode) { + return () => { + partialUpdate({ audioBitrateMode: value }); + }; + }, + setCustomAudioBitrate(value: AdvancedSettingsCustomAudioBitrate) { + return () => { + partialUpdate({ customAudioBitrate: value }); + }; + }, + setEnableDtx(value: boolean) { + return () => { + partialUpdate({ enableDtx: value }); + }; + }, + setPublisherAudioFallbackEnabled(value: boolean) { + return () => { + partialUpdate({ publisherAudioFallbackEnabled: value }); + }; + }, + setSubscriberAudioFallbackEnabled(value: boolean) { + return () => { + partialUpdate({ subscriberAudioFallbackEnabled: value }); + }; + }, + setPublisherStatisticsEnabled(value: boolean) { + return () => { + partialUpdate({ publisherStatisticsEnabled: value }); + }; + }, + }, +}); + +const internals = actions(advancedSettings$, { + update: (updatedValues: Partial) => { + return ({ setState }) => { + setState((state) => ({ ...state, ...updatedValues })); + }; + }, +}); + +function partialUpdate(partialState: Partial) { + internals.update(partialState); +} + +export default advancedSettings$; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/index.ts new file mode 100644 index 00000000..509294f2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/AdvancedSettings/index.ts @@ -0,0 +1,2 @@ +export { default } from './AdvancedSettings'; +export type { advancedSettings } from './AdvancedSettings'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/BackgroundEffectsDialogContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/BackgroundEffectsDialogContext.tsx new file mode 100644 index 00000000..3cdde4a8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/BackgroundEffectsDialogContext.tsx @@ -0,0 +1,27 @@ +import createContext from 'react-global-state-hooks/createContext'; + +/** + * BackgroundEffectsDialog Context + * Manages the state of the Background Effects Dialog. + */ +const backgroundEffectsDialog$ = createContext( + { + isOpen: false, + }, + { + actions: { + open() { + return ({ setState }) => { + setState((state) => ({ ...state, isOpen: true })); + }; + }, + close() { + return ({ setState }) => { + setState((state) => ({ ...state, isOpen: false })); + }; + }, + }, + } +); + +export default backgroundEffectsDialog$; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/index.ts new file mode 100644 index 00000000..9800299f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundEffectsDialog/index.ts @@ -0,0 +1 @@ +export { default } from './BackgroundEffectsDialogContext'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/index.tsx new file mode 100644 index 00000000..cf132329 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/index.tsx @@ -0,0 +1,31 @@ +import { ReactElement, ReactNode, createContext } from 'react'; +import useBackgroundPublisher from './useBackgroundPublisher'; + +export type BackgroundPublisherContextType = ReturnType; +export const BackgroundPublisherContext = createContext({} as BackgroundPublisherContextType); + +export type BackgroundPublisherProviderProps = { + children: ReactNode; +}; + +/** + * BackgroundPublisherProvider - React Context Provider for BackgroundPublisherContext + * BackgroundPublisherContextType contains all state and methods for local video publisher + * See useBackgroundPublisher.tsx for methods and state + * @param {BackgroundPublisherProviderProps} props - Background publisher provider properties + * @property {ReactNode} children - The content to be rendered + * @returns {BackgroundPublisherContext} a context provider for a publisher Background + */ +export const BackgroundPublisherProvider = ({ + children, +}: { + children: ReactNode; +}): ReactElement => { + const backgroundPublisherContext = useBackgroundPublisher(); + + return ( + + {children} + + ); +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx new file mode 100644 index 00000000..30ce0a82 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/index.tsx @@ -0,0 +1,3 @@ +import useBackgroundPublisher from './useBackgroundPublisher'; + +export default useBackgroundPublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx new file mode 100644 index 00000000..e3b16181 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.spec.tsx @@ -0,0 +1,269 @@ +import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; +import EventEmitter from 'node:events'; +import { defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import useBackgroundPublisher from './useBackgroundPublisher'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@vonage/client-sdk-video'); + +describe('useBackgroundPublisher', () => { + const mockPublisher = Object.assign(new EventEmitter(), { + getAudioSource: () => defaultAudioDevice, + getVideoSource: () => defaultVideoDevice, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + setPreferredFrameRate: vi.fn().mockResolvedValue(undefined), + setPreferredResolution: vi.fn().mockResolvedValue(undefined), + setMaxVideoBitrate: vi.fn().mockResolvedValue(undefined), + setVideoBitratePreset: vi.fn().mockResolvedValue(undefined), + }) as unknown as Publisher; + + const mockedInitPublisher = vi.fn(); + const mockedHasMediaProcessorSupport = vi.fn(); + + beforeEach(() => { + vi.spyOn(console, 'error'); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + + (initPublisher as Mock).mockImplementation(mockedInitPublisher); + (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); + }); + + describe('initBackgroundLocalPublisher', () => { + it('should call initBackgroundLocalPublisher', () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = render(); + + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + + expect(mockedInitPublisher).toHaveBeenCalled(); + }); + + it('should log access denied errors', () => { + const error = new Error( + "It hit me pretty hard, how there's no kind of sad in this world that will stop it turning." + ); + error.name = 'OT_USER_MEDIA_ACCESS_DENIED'; + (initPublisher as Mock).mockImplementation((_, _args, callback) => { + callback(error); + }); + + const { result } = render(); + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + expect(console.error).toHaveBeenCalledWith('initPublisher error: ', error); + }); + + it('should apply background high blur-sm when initialized and changed background', async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = render(); + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + + await act(async () => { + await result.current.changeBackground('high-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'high', + }); + }); + + it('should not replace background when initialized if the device does not support it', () => { + mockedHasMediaProcessorSupport.mockReturnValue(false); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = render(); + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + expect(mockedInitPublisher).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + videoFilter: undefined, + }), + expect.any(Function) + ); + }); + }); + + describe('changeBackground', () => { + let result: ReturnType['result']; + beforeEach(() => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + result = render().result; + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + (mockPublisher.applyVideoFilter as Mock).mockClear(); + (mockPublisher.clearVideoFilter as Mock).mockClear(); + }); + + it('applies low blur-sm filter', async () => { + await act(async () => { + await result.current.changeBackground('low-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('applies background replacement with image', async () => { + await act(async () => { + await result.current.changeBackground('bg1.jpg'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: expect.stringContaining('bg1.jpg'), + }); + }); + + it('clears video filter for unknown option', async () => { + await act(async () => { + await result.current.changeBackground('none'); + }); + expect(mockPublisher.clearVideoFilter).toHaveBeenCalled(); + }); + + it('logs an error if applyBackgroundFilter rejects', async () => { + mockPublisher.applyVideoFilter = vi.fn(() => { + throw new Error('Simulated internal failure'); + }); + + const { result: res } = render(); + await act(async () => { + res.current.initBackgroundLocalPublisher(); + await res.current.changeBackground('low-blur'); + }); + + expect(console.error).toHaveBeenCalledWith('Failed to apply background filter.'); + }); + }); + + describe('on accessDenied', () => { + const mockQuery = vi.fn(); + let mockedPermissionStatus: { onchange: null | (() => void); state: string }; + const emitAccessDeniedError = () => { + // @ts-expect-error We simulate user denying microphone permissions in a browser. + mockPublisher.emit('accessDenied', { + message: 'Microphone permission denied during the call', + }); + }; + + beforeEach(() => { + mockedPermissionStatus = { + onchange: null, + state: 'prompt', + }; + mockQuery.mockResolvedValue(mockedPermissionStatus); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockImplementation(mockQuery); + }); + + it('handles permission denial', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = render(); + + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + + expect(result.current.accessStatus).not.toBe(DEVICE_ACCESS_STATUS.REJECTED); + + act(() => { + emitAccessDeniedError(); + }); + + await waitFor(() => { + expect(result.current.accessStatus).toBe(DEVICE_ACCESS_STATUS.REJECTED); + }); + }); + + it('does not throw on older, unsupported browsers', async () => { + mockQuery.mockImplementation(() => { + return Promise.reject(new Error('Whoops')); + }); + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = render(); + + act(() => { + result.current.initBackgroundLocalPublisher(); + }); + + expect(emitAccessDeniedError).not.toThrow(); + + act(() => { + emitAccessDeniedError(); + }); + + await waitFor(() => { + expect(console.error).toHaveBeenCalledWith( + 'Error querying permissions:', + expect.objectContaining({ message: 'Whoops' }) + ); + }); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; +}; + +function render({ + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, +}: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderHookBase(() => useBackgroundPublisher(), { + wrapper, + }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx new file mode 100644 index 00000000..7ef53442 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/BackgroundPublisherProvider/useBackgroundPublisher/useBackgroundPublisher.tsx @@ -0,0 +1,320 @@ +import { useState, useRef, useCallback } from 'react'; +import { + Publisher, + Event, + initPublisher, + VideoFilter, + hasMediaProcessorSupport, + PublisherProperties, +} from '@vonage/client-sdk-video'; +import usePermissions from '../../../hooks/usePermissions'; +import useUserContext from '../../../hooks/useUserContext'; +import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; +import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePublisher'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; +import useImageStorage, { StoredImage } from '../../../utils/useImageStorage/useImageStorage'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import handlePublisherAccessDenied from '../../../utils/publisher/handlePublisherAccessDenied'; +import mediaDevices$ from '@core/stores/devices'; +import useSyncPublisherDevices from '@Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices'; +import { getStorageItem, STORAGE_KEYS } from '@utils/storage'; +import attempt from '@common/execution/attempt/attempt'; +import { useMountEffect } from '@web/hooks'; +import { env } from '../../../env'; + +export type BackgroundPublisherContextType = { + isPublishing: boolean; + isVideoEnabled: boolean; + publisher: Publisher | null; + publisherVideoElement: HTMLVideoElement | HTMLObjectElement | undefined; + destroyBackgroundPublisher: () => void; + toggleVideo: () => void; + changeBackground: (backgroundSelected: string) => Promise; + backgroundFilter: VideoFilter | undefined; + accessStatus: string | null; + initBackgroundLocalPublisher: () => void; + customImages: StoredImage[]; + addCustomImage: (dataUrl: string) => void; + deleteCustomImage: (id: string) => void; + backgroundSelected: string; + setBackgroundSelected: (value: string) => void; + handleBackgroundChange: (background: string) => void; + handleAddCustomImage: (dataUrl: string) => void; +}; + +type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { + element: HTMLVideoElement | HTMLObjectElement; +}; + +export type BackgroundPublisherContextInitialValue = Partial< + Pick< + BackgroundPublisherContextType, + | 'publisherVideoElement' + | 'isPublishing' + | 'isVideoEnabled' + | 'customImages' + | 'backgroundSelected' + | 'backgroundFilter' + | 'publisher' + > +>; + +/** + * Hook wrapper for creation, interaction with, and state for local video publisher with background effects. + * Access from app via BackgroundPublisherProvider, not directly. + * @property {boolean} isPublishing - React state boolean showing if we are publishing + * @property {boolean} isVideoEnabled - React state boolean showing if camera is on + * @property {Publisher | null} publisher - Publisher object + * @property {HTMLVideoElement | HTMLObjectElement} publisherVideoElement - video element for publisher + * @property {Function} destroyBackgroundPublisher - Method to destroy publisher + * @property {Function} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. + * @property {Function} changeBackground - Method to change background effect + * @property {VideoFilter | undefined} backgroundFilter - Current background filter applied to publisher + * @property {string | null} accessStatus - Current device access status + * @property {Function} initBackgroundLocalPublisher - Method to initialize the background publisher + * @returns {BackgroundPublisherContextType} Background context + */ +const useBackgroundPublisher = ( + initialValue?: BackgroundPublisherContextInitialValue +): BackgroundPublisherContextType => { + const { user } = useUserContext(); + const { getImagesFromStorage, addImageToStorage, deleteImageFromStorage } = useImageStorage(); + const [publisherVideoElement, setPublisherVideoElement] = useState< + HTMLVideoElement | HTMLObjectElement | undefined + >(initialValue?.publisherVideoElement ?? undefined); + const { setAccessStatus, accessStatus } = usePermissions(); + + const backgroundPublisherRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(initialValue?.isPublishing ?? false); + + const initialBackgroundRef = useRef( + user.defaultSettings.backgroundFilter + ); + + const [backgroundFilter, setBackgroundFilter] = useState( + () => initialValue?.backgroundFilter ?? user.defaultSettings.backgroundFilter + ); + + const [isVideoEnabled, setIsVideoEnabled] = useState( + initialValue?.isVideoEnabled ?? getStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED) !== 'false' + ); + + const [customImages, setCustomImages] = useState( + () => initialValue?.customImages ?? getImagesFromStorage() + ); + + const [backgroundSelected, setBackgroundSelected] = useState( + initialValue?.backgroundSelected ?? '' + ); + + const handleBackgroundDestroyed = () => { + backgroundPublisherRef.current = null; + }; + + // Sync publisher with selected devices from store (handles device changes and disconnections) + useSyncPublisherDevices(backgroundPublisherRef, { setIsVideoEnabled }); + + /** + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option + * @returns {void} + */ + const changeBackground = useCallback( + (backgroundSelected: string) => { + return applyBackgroundFilter({ + publisher: backgroundPublisherRef.current, + backgroundSelected, + setUser: undefined, + setBackgroundFilter, + storeItem: false, + }).catch(() => { + console.error('Failed to apply background filter.'); + }); + }, + [setBackgroundFilter] + ); + + const handleBackgroundAccessDenied = useCallback( + async (event: AccessDeniedEvent) => { + await handlePublisherAccessDenied(event, setAccessStatus); + }, + [setAccessStatus] + ); + + const handleVideoElementCreated = (event: PublisherVideoElementCreatedEvent) => { + setPublisherVideoElement(event.element); + setIsPublishing(true); + }; + + const addPublisherListeners = useCallback( + (publisher: Publisher | null) => { + if (!publisher) { + return; + } + publisher.on('destroyed', handleBackgroundDestroyed); + publisher.on('accessDenied', handleBackgroundAccessDenied); + publisher.on('videoElementCreated', handleVideoElementCreated); + publisher.on('accessAllowed', () => { + setAccessStatus(DEVICE_ACCESS_STATUS.ACCEPTED); + }); + }, + [handleBackgroundAccessDenied, setAccessStatus] + ); + + /** + * Destroys the background publisher + * @returns {void} + */ + const destroyBackgroundPublisher = useCallback(() => { + attempt(() => { + backgroundPublisherRef.current?.destroy(); + }); + + backgroundPublisherRef.current = null; + }, []); + + const initBackgroundLocalPublisher = useCallback(() => { + if (backgroundPublisherRef.current) return; + + let videoFilter: VideoFilter | undefined; + if (initialBackgroundRef.current && hasMediaProcessorSupport('both')) { + videoFilter = initialBackgroundRef.current; + } + + const publisherOptions: PublisherProperties = { + insertDefaultUI: false, + videoFilter, + resolution: env.DEFAULT_RESOLUTION, + videoSource: mediaDevices$.getState().videoinput, + publishAudio: false, + publishVideo: isVideoEnabled, + }; + + backgroundPublisherRef.current = initPublisher(undefined, publisherOptions, (err: unknown) => { + if (err instanceof Error) { + backgroundPublisherRef.current = null; + if (err.name === 'OT_USER_MEDIA_ACCESS_DENIED') { + console.error('initPublisher error: ', err); + } + } + }); + addPublisherListeners(backgroundPublisherRef.current); + }, [addPublisherListeners, isVideoEnabled]); + + /** + * Turns the camera on and off + * A wrapper for Publisher.publishVideo() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishVideo + * @returns {void} + */ + const toggleVideo = () => { + if (!backgroundPublisherRef.current) { + return; + } + backgroundPublisherRef.current.publishVideo(!isVideoEnabled); + setIsVideoEnabled(!isVideoEnabled); + }; + + /** + * Add a custom background image + * @param {string} dataUrl - The data URL of the image to add + * @returns {void} + */ + const addCustomImage = useCallback( + (dataUrl: string) => { + addImageToStorage(dataUrl); + setCustomImages(getImagesFromStorage()); + }, + [getImagesFromStorage, addImageToStorage] + ); + + /** + * Delete a custom background image + * If the deleted image is currently selected, clear the background filter + * @param {string} id - The ID of the image to delete + * @returns {void} + */ + const deleteCustomImage = useCallback( + (id: string) => { + const imageToDelete = customImages.find((img) => img.id === id); + if (!imageToDelete) { + throw new Error('Image to delete not found'); + } + + // Don't allow deletion if this image is currently selected + const isSelectedBackground = backgroundSelected === imageToDelete.dataUrl; + if (isSelectedBackground) { + throw new Error('Cannot delete currently selected background image'); + } + + deleteImageFromStorage(id); + setCustomImages((imgs) => imgs.filter((img) => img.id !== id)); + + // If the deleted image was the currently applied background filter, clear it + const currentBackgroundFilter = getInitialBackgroundFilter(backgroundPublisherRef.current); + if (imageToDelete.dataUrl === currentBackgroundFilter) { + changeBackground(backgroundSelected).catch(() => { + throw new Error('Failed to reset background filter after deleting custom image'); + }); + } + }, + [backgroundSelected, customImages, deleteImageFromStorage, changeBackground] + ); + + /** + * Handle background change by updating state and applying the filter + * @param {string} background - The background option to apply + * @returns {void} + */ + const handleBackgroundChange = useCallback( + (background: string) => { + setBackgroundSelected(background); + changeBackground(background).catch((error) => { + console.error('Failed to change background:', error); + }); + }, + [changeBackground] + ); + + /** + * Handle adding a custom image and immediately apply it as background + * @param {string} dataUrl - The data URL of the image to add + * @returns {void} + */ + const handleAddCustomImage = useCallback( + (dataUrl: string) => { + addCustomImage(dataUrl); + handleBackgroundChange(dataUrl); + }, + [addCustomImage, handleBackgroundChange] + ); + + useMountEffect(() => { + return () => { + destroyBackgroundPublisher(); + }; + }); + + return { + initBackgroundLocalPublisher, + isPublishing, + isVideoEnabled, + destroyBackgroundPublisher, + + publisher: backgroundPublisherRef.current, + publisherVideoElement, + toggleVideo, + changeBackground, + backgroundFilter, + accessStatus, + + customImages, + addCustomImage, + deleteCustomImage, + backgroundSelected, + setBackgroundSelected, + handleBackgroundChange, + handleAddCustomImage, + }; +}; +export default useBackgroundPublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.spec.tsx new file mode 100644 index 00000000..8816dbe2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.spec.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render as renderBase, act, waitFor } from '@testing-library/react'; +import frontendLogger from '../../logger'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import LoggerSynchronizer from './LoggerSynchronizer'; + +vi.mock('../../logger', () => ({ + default: { + setContext: vi.fn(), + clearContext: vi.fn(), + }, +})); + +const mockSetContext = vi.mocked(frontendLogger.setContext); +const mockClearContext = vi.mocked(frontendLogger.clearContext); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockVonageVideoClient = { sessionId: 'session-1', connectionId: 'connection-1' } as any; + +describe('LoggerSynchronizer', () => { + it('syncs userId, sessionId, and connectionId into the logger on mount', () => { + render({ + sessionContext: { + __interceptor: (ctx) => { + if (ctx) ctx.vonageVideoClient = mockVonageVideoClient; + }, + }, + }); + + expect(mockSetContext).toHaveBeenCalledWith({ + userId: 'user-1', + sessionId: 'session-1', + connectionId: 'connection-1', + }); + }); + + it('syncs undefined sessionId and connectionId when no session is connected', () => { + render(); + + expect(mockSetContext).toHaveBeenCalledWith({ + userId: 'user-1', + sessionId: undefined, + connectionId: undefined, + }); + }); + + it('sets userId to undefined when user name is empty', () => { + render({ userContext: { value: { defaultSettings: { name: '' } } } }); + + expect(mockSetContext).toHaveBeenCalledWith(expect.objectContaining({ userId: undefined })); + }); + + it('re-syncs when userId changes', async () => { + const { userContext } = render(); + + act(() => { + userContext.current?.setUser((prev) => ({ + ...prev, + defaultSettings: { ...prev.defaultSettings, name: 'new-name' }, + })); + }); + + await waitFor(() => { + expect(mockSetContext).toHaveBeenLastCalledWith( + expect.objectContaining({ userId: 'new-name' }) + ); + }); + }); + + it('never calls clearContext so async events always keep their context', () => { + const { unmount } = render(); + + unmount(); + + expect(mockClearContext).not.toHaveBeenCalled(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render({ userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime] as const, + { + userContext: { + value: { defaultSettings: { name: 'user-1' } }, + ...userContext, + }, + sessionContext, + runtimeContext: undefined, + } + ); + + const rendered = renderBase(, { wrapper }); + + return { ...rendered, ...context }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.tsx new file mode 100644 index 00000000..88e76bc3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/LoggerSynchronizer.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import useUserContext from '@hooks/useUserContext'; +import frontendLogger from '../../logger'; + +const LoggerSynchronizer = () => { + const { vonageVideoClient } = useSessionContext(); + const { user } = useUserContext(); + + const userId = user.defaultSettings.name || undefined; + const sessionId = vonageVideoClient?.sessionId; + const connectionId = vonageVideoClient?.connectionId; + + useEffect(() => { + frontendLogger.setContext({ userId, sessionId, connectionId }); + }, [userId, sessionId, connectionId]); + + return null; +}; + +export default LoggerSynchronizer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/index.tsx new file mode 100644 index 00000000..8d44371f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/LoggerSynchronizer/index.tsx @@ -0,0 +1 @@ +export { default } from './LoggerSynchronizer'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/PrecallNetworkTestDialogContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/PrecallNetworkTestDialogContext.tsx new file mode 100644 index 00000000..5c4c0965 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/PrecallNetworkTestDialogContext.tsx @@ -0,0 +1,27 @@ +import createContext from 'react-global-state-hooks/createContext'; + +/** + * PrecallNetworkTestDialog Context + * Manages the state of the Precall Network Test Dialog. + */ +const precallNetworkTestDialog$ = createContext( + { + isOpen: false, + }, + { + actions: { + open() { + return ({ setState }) => { + setState((state) => ({ ...state, isOpen: true })); + }; + }, + close() { + return ({ setState }) => { + setState((state) => ({ ...state, isOpen: false })); + }; + }, + }, + } +); + +export default precallNetworkTestDialog$; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/index.ts new file mode 100644 index 00000000..3861d879 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PrecallNetworkTestDialog/index.ts @@ -0,0 +1 @@ +export { default } from './PrecallNetworkTestDialogContext'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/index.tsx new file mode 100644 index 00000000..faed2b7c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/index.tsx @@ -0,0 +1,29 @@ +import { ReactNode, createContext } from 'react'; +import usePreviewPublisher from './usePreviewPublisher'; + +export type PreviewPublisherContextType = ReturnType; +export const PreviewPublisherContext = createContext({} as PreviewPublisherContextType); + +export type PreviewPublisherProviderProps = { + children: ReactNode; +}; + +/** + * PreviewPublisherProvider - React Context Provider for PublisherContext + * PublisherContext contains all state and methods for local video publisher + * We use Context to make the publisher available in many components across the app without + * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context + * See usePreviewPublisher.tsx for methods and state + * @param {PreviewPublisherProviderProps} props - The provider properties + * @property {ReactNode} children - The content to be rendered + * @returns {PreviewPublisherContext} a context provider for a publisher preview + */ +export const PreviewPublisherProvider = ({ children }: { children: ReactNode }) => { + const previewPublisherContext = usePreviewPublisher(); + + return ( + + {children} + + ); +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/index.tsx new file mode 100644 index 00000000..09b3808c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/index.tsx @@ -0,0 +1,3 @@ +import usePreviewPublisher from './usePreviewPublisher'; + +export default usePreviewPublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx new file mode 100644 index 00000000..d79fe37c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.spec.tsx @@ -0,0 +1,241 @@ +import { act, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { hasMediaProcessorSupport, initPublisher, Publisher } from '@vonage/client-sdk-video'; +import EventEmitter from 'node:events'; +import { defaultAudioDevice, defaultVideoDevice } from '@utils/mockData/device'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import usePreviewPublisher from './usePreviewPublisher'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import renderAsyncHook from '@web-test/renderAsyncHook'; +import composeProviders from '@web/helpers/composeProviders'; +import SuspenseBoundary from '@web/components/SuspenseBoundary'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@vonage/client-sdk-video'); + +describe('usePreviewPublisher', () => { + const mockPublisher = Object.assign(new EventEmitter(), { + getAudioSource: () => defaultAudioDevice, + getVideoSource: () => defaultVideoDevice, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + }) as unknown as Publisher; + const mockedInitPublisher = vi.fn(); + const mockedHasMediaProcessorSupport = vi.fn(); + + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(vi.fn()); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + + (initPublisher as Mock).mockImplementation(mockedInitPublisher); + (hasMediaProcessorSupport as Mock).mockImplementation(mockedHasMediaProcessorSupport); + }); + + describe('initLocalPublisher', () => { + it('should call initLocalPublisher', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = await render(); + + result.current.initLocalPublisher(); + + expect(mockedInitPublisher).toHaveBeenCalled(); + }); + + it('should log access denied errors', async () => { + const error = new Error( + "It hit me pretty hard, how there's no kind of sad in this world that will stop it turning." + ); + error.name = 'OT_USER_MEDIA_ACCESS_DENIED'; + (initPublisher as Mock).mockImplementation((_, _args, callback) => { + callback(error); + }); + + const { result } = await render(); + result.current.initLocalPublisher(); + expect(console.error).toHaveBeenCalledWith('initPublisher error: ', error); + }); + + it('should apply background high blur-sm when initialized and changed background', async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = await render(); + result.current.initLocalPublisher(); + + await act(async () => { + await result.current.changeBackground('high-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'high', + }); + }); + + it('should not replace background when initialized if the device does not support it', async () => { + mockedHasMediaProcessorSupport.mockReturnValue(false); + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = await render(); + result.current.initLocalPublisher(); + expect(mockedInitPublisher).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + videoFilter: undefined, + }), + expect.any(Function) + ); + }); + }); + + describe('changeBackground', () => { + let result: ReturnType; + + beforeEach(async () => { + mockedHasMediaProcessorSupport.mockReturnValue(true); + mockedInitPublisher.mockReturnValue(mockPublisher); + const renderResult = await render(); + + result = renderResult.result.current; + + act(() => { + result.initLocalPublisher(); + }); + + (mockPublisher.applyVideoFilter as Mock).mockClear(); + (mockPublisher.clearVideoFilter as Mock).mockClear(); + }); + + it('applies low blur-sm filter', async () => { + await act(async () => { + await result.changeBackground('low-blur'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundBlur', + blurStrength: 'low', + }); + }); + + it('applies background replacement with image', async () => { + await act(async () => { + await result.changeBackground('bg1.jpg'); + }); + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: expect.stringContaining('bg1.jpg'), + }); + }); + + it('clears video filter for unknown option', async () => { + await act(async () => { + await result.changeBackground('none'); + }); + }); + + it('logs an error if applyBackgroundFilter rejects', async () => { + mockPublisher.applyVideoFilter = vi.fn(() => { + throw new Error('Simulated internal failure'); + }); + + const { result: res } = await render(); + await act(async () => { + res.current.initLocalPublisher(); + await res.current.changeBackground('low-blur'); + }); + + expect(console.error).toHaveBeenCalledWith('Failed to apply background filter.'); + }); + }); + + describe('on accessDenied', () => { + const mockQuery = vi.fn(); + let mockedPermissionStatus: { onchange: null | (() => void); status: string }; + const emitAccessDeniedError = () => { + // @ts-expect-error We simulate user denying microphone permissions in a browser. + mockPublisher.emit('accessDenied', { + message: 'Microphone permission denied during the call', + }); + }; + + beforeEach(() => { + mockedPermissionStatus = { + onchange: null, + status: 'prompt', + }; + mockQuery.mockResolvedValue(mockedPermissionStatus); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockImplementation(mockQuery); + }); + + it('handles permission denial', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = await render(); + + act(() => { + result.current.initLocalPublisher(); + }); + + expect(result.current.accessStatus).not.toBe(DEVICE_ACCESS_STATUS.REJECTED); + + act(() => { + emitAccessDeniedError(); + }); + + await waitFor(() => { + expect(result.current.accessStatus).toBe(DEVICE_ACCESS_STATUS.REJECTED); + }); + }); + + it('does not throw on older, unsupported browsers', async () => { + mockQuery.mockImplementation(() => { + return Promise.reject(new Error('Whoops')); + }); + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = await render(); + + act(() => { + result.current.initLocalPublisher(); + }); + + expect(emitAccessDeniedError).not.toThrow(); + + act(emitAccessDeniedError); + + expect(console.error).toHaveBeenCalledWith('Error querying permissions:', expect.any(Error)); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; +}; + +async function render({ userContext, previewPublisherContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider([providers.user, providers.previewPublisher], { + userContext, + previewPublisherContext, + }); + + const composedWrapper = composeProviders(SuspenseBoundary, wrapper); + + const rendered = await renderAsyncHook(() => usePreviewPublisher(), { + wrapper: composedWrapper, + }); + + return { + ...rendered, + ...context, + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx new file mode 100644 index 00000000..eeafcf95 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PreviewPublisherProvider/usePreviewPublisher/usePreviewPublisher.tsx @@ -0,0 +1,296 @@ +import { useState, useRef, useCallback } from 'react'; +import { initPublisher, hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import type { Event, Publisher, PublisherProperties, VideoFilter } from '@vonage/client-sdk-video'; +import usePermissions from '../../../hooks/usePermissions'; +import useUserContext from '../../../hooks/useUserContext'; +import { DEVICE_ACCESS_STATUS } from '../../../utils/constants'; +import { UserType } from '../../user'; +import { AccessDeniedEvent } from '../../PublisherProvider/usePublisher/usePublisher'; +import { setStorageItem, getStorageItem, STORAGE_KEYS } from '../../../utils/storage'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; +import handlePublisherAccessDenied from '../../../utils/publisher/handlePublisherAccessDenied'; +import useStableCallback from '@web/hooks/useStableCallback'; +import mediaDevices$ from '@core/stores/devices'; +import useSyncPublisherDevices from '@Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/useSyncPublisherDevices'; +import waitUntilPlaying from '@utils/waitUntilPlaying'; +import { attempt } from '@common/execution'; +import { useMountEffect } from '@web/hooks'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import useApplyAdvancedSettings from '@Context/PublisherProvider/useApplyAdvancedSettings'; +import { env } from '../../../env'; + +type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { + element: HTMLVideoElement | HTMLObjectElement; +}; + +export type PreviewPublisherContextType = { + isAudioEnabled: boolean; + isPublishing: boolean; + isVideoEnabled: boolean; + publisher: Publisher | null; + publisherVideoElement: HTMLVideoElement | HTMLObjectElement | undefined; + destroyPublisher: () => void; + toggleAudio: () => void; + toggleVideo: () => void; + changeBackground: (backgroundSelected: string) => Promise; + backgroundFilter: VideoFilter | undefined; + accessStatus: string | null; + initLocalPublisher: () => void; + speechLevel: number; + isVideoLoading: boolean; +}; + +export type PreviewPublisherInitialValue = Partial< + Pick< + PreviewPublisherContextType, + | 'publisherVideoElement' + | 'isAudioEnabled' + | 'isPublishing' + | 'isVideoEnabled' + | 'speechLevel' + | 'backgroundFilter' + | 'accessStatus' + | 'publisher' + > +>; + +/** + * Hook wrapper for creation, interaction with, and state for local video preview publisher. + * Access from app via PreviewPublisherProvider, not directly. + * @property {boolean} isAudioEnabled - React state boolean showing if audio is enabled + * @property {boolean} isPublishing - React state boolean showing if we are publishing + * @property {boolean} isVideoEnabled - React state boolean showing if camera is on + * @property {Publisher | null} publisher - Publisher object + * @property {HTMLVideoElement | HTMLObjectElement} publisherVideoElement - video element for publisher + * @property {Function} destroyPublisher - Method to destroy publisher + * @property {() => void} toggleAudio - Method to toggle microphone on/off. State updated internally, can be read via isAudioEnabled. + * @property {() => void} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. + * @property {Function} changeBackground - Method to change background effect + * @property {VideoFilter | undefined} backgroundFilter - Current background filter applied to publisher + * @property {string | undefined} localVideoSource - Current video source device ID + * @property {string | undefined} localAudioSource - Current audio source device ID + * @property {string | null} accessStatus - Current device access status + * @property {Function} initLocalPublisher - Method to initialize the preview publisher + * @property {number} speechLevel - Current speech level for audio visualization + * @returns {PreviewPublisherContextType} preview context + */ +const usePreviewPublisher = ( + initialValue?: PreviewPublisherInitialValue +): PreviewPublisherContextType => { + const videoSourceId = mediaDevices$.useDeviceId('videoinput'); + const audioSourceId = mediaDevices$.useDeviceId('audioinput'); + + const { setUser, user } = useUserContext(); + const [publisherVideoElement, setPublisherVideoElement] = useState< + HTMLVideoElement | HTMLObjectElement | undefined + >(initialValue?.publisherVideoElement ?? undefined); + const [speechLevel, setSpeechLevel] = useState(initialValue?.speechLevel ?? 0); + const { setAccessStatus, accessStatus } = usePermissions(); + const publisherRef = useRef(null); + + const [isPublishing, setIsPublishing] = useState(initialValue?.isPublishing ?? false); + const initialBackgroundRef = useRef( + user.defaultSettings.backgroundFilter + ); + const [backgroundFilter, setBackgroundFilter] = useState( + () => initialValue?.backgroundFilter ?? user.defaultSettings.backgroundFilter + ); + const [isVideoEnabled, setIsVideoEnabled] = useState( + () => + initialValue?.isVideoEnabled ?? getStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED) !== 'false' + ); + + const [isAudioEnabled, setIsAudioEnabled] = useState( + () => + initialValue?.isAudioEnabled ?? getStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED) !== 'false' + ); + + const handlePreviewDestroyed = () => { + publisherRef.current = null; + }; + + const [isVideoLoading, setIsVideoLoading] = useState(isVideoEnabled); + + // Sync publisher with selected devices from store (handles device changes and disconnections) + useSyncPublisherDevices(publisherRef, { setIsAudioEnabled, setIsVideoEnabled }); + + /** + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option + * @returns {void} + */ + const changeBackground = useCallback( + (backgroundSelected: string) => { + return applyBackgroundFilter({ + publisher: publisherRef.current, + backgroundSelected, + setUser, + setBackgroundFilter, + }).catch(() => { + console.error('Failed to apply background filter.'); + }); + }, + [setBackgroundFilter, setUser] + ); + + const handleAccessDenied = useCallback( + async (event: AccessDeniedEvent) => { + await handlePublisherAccessDenied(event, setAccessStatus); + }, + [setAccessStatus] + ); + + const handleVideoElementCreated = (event: PublisherVideoElementCreatedEvent) => { + setPublisherVideoElement(event.element); + setIsPublishing(true); + + event.element.classList.add('video__element'); + event.element.title = 'publisher-preview'; + + if (!isVideoLoading) return; + + void waitUntilPlaying(event.element).then(() => { + setIsVideoLoading(false); + }); + }; + + /* TODO: Replace with mvgAverage utils once merged */ // NOSONAR + const calculateAudioLevel = useCallback((audioLevel: number) => { + const currentLogLevel = Math.log(audioLevel) / Math.LN10 / 1.5 + 1; + setSpeechLevel(Math.min(Math.max(currentLogLevel, 0), 1) * 100); + }, []); + + const addPublisherListeners = useStableCallback((publisher: Publisher | null) => { + if (!publisher) { + return; + } + publisher.on('destroyed', handlePreviewDestroyed); + publisher.on('accessDenied', handleAccessDenied); + publisher.on('videoElementCreated', handleVideoElementCreated); + publisher.on('audioLevelUpdated', ({ audioLevel }: { audioLevel: number }) => { + calculateAudioLevel(audioLevel); + }); + publisher.on('accessAllowed', () => { + setAccessStatus(DEVICE_ACCESS_STATUS.ACCEPTED); + }); + }); + + const initLocalPublisher = useStableCallback(() => { + if (publisherRef.current) { + return; + } + + let videoFilter: VideoFilter | undefined; + if (initialBackgroundRef.current && hasMediaProcessorSupport('both')) { + videoFilter = initialBackgroundRef.current; + } + + const { frameRate, codecMode, codecPriority } = advancedSettings$.getState(); + + const publisherOptions: PublisherProperties = { + insertDefaultUI: false, + videoFilter, + resolution: env.PUBLISHER_MAX_RESOLUTION, + frameRate, + preferredVideoCodecs: codecMode === 'automatic' ? 'automatic' : codecPriority, + publishAudio: isAudioEnabled, + publishVideo: isVideoEnabled, + audioSource: audioSourceId, + videoSource: videoSourceId, + }; + + publisherRef.current = initPublisher(undefined, publisherOptions, (err: unknown) => { + if (err instanceof Error) { + publisherRef.current = null; + if (err.name === 'OT_USER_MEDIA_ACCESS_DENIED') { + console.error('initPublisher error: ', err); + } + } + }); + + addPublisherListeners(publisherRef.current); + }); + + /** + * Destroys the preview publisher + * @returns {void} + */ + const destroyPublisher = useCallback(() => { + if (!publisherRef.current) return; + + attempt(() => { + // There is a known race condition in Firefox during navigation where the DOM elements are destroyed before the publisher is destroyed, causing OT to throw an error. + publisherRef.current?.destroy(); + }); + }, []); + + /** + * Turns the camera on and off + * A wrapper for Publisher.publishVideo() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishVideo + * @returns {void} + */ + const toggleVideo = () => { + if (!publisherRef.current) { + return; + } + + publisherRef.current.publishVideo(!isVideoEnabled); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, (!isVideoEnabled).toString()); + setIsVideoEnabled(!isVideoEnabled); + if (setUser) { + setUser((prevUser: UserType) => ({ + ...prevUser, + defaultSettings: { ...prevUser.defaultSettings, publishVideo: !isVideoEnabled }, + })); + } + }; + + /** + * Turns the microphone on and off + * A wrapper for Publisher.publishAudio() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishAudio + * @returns {void} + */ + const toggleAudio = () => { + if (!publisherRef.current) { + return; + } + publisherRef.current.publishAudio(!isAudioEnabled); + setIsAudioEnabled(!isAudioEnabled); + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, (!isAudioEnabled).toString()); + if (setUser) { + setUser((prevUser: UserType) => ({ + ...prevUser, + defaultSettings: { ...prevUser.defaultSettings, publishAudio: !isAudioEnabled }, + })); + } + }; + + useMountEffect(() => { + return () => { + destroyPublisher(); + }; + }); + + useApplyAdvancedSettings(isPublishing ? publisherRef.current : null); + + return { + isAudioEnabled, + initLocalPublisher, + isPublishing, + isVideoEnabled, + destroyPublisher, + + publisher: publisherRef.current, + publisherVideoElement, + toggleAudio, + toggleVideo, + changeBackground, + backgroundFilter, + accessStatus, + speechLevel, + isVideoLoading, + }; +}; +export default usePreviewPublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/PublisherProvider.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/PublisherProvider.tsx new file mode 100644 index 00000000..f07e999b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/PublisherProvider.tsx @@ -0,0 +1,30 @@ +import { ReactElement, ReactNode, createContext } from 'react'; +import usePublisher from './usePublisher'; +import { PublisherContextInitialValue } from './usePublisher/usePublisher'; + +export type PublisherContextType = ReturnType; +export const PublisherContext = createContext({} as PublisherContextType); + +export type PublisherProviderProps = { + children: ReactNode; + initialValue?: PublisherContextInitialValue; +}; + +/** + * PublisherProvider - React Context Provider for PublisherContext + * PublisherContext contains all state and methods for local video publisher + * We use Context to make the publisher available in many components across the app without + * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context + * See usePublisher.tsx for methods and state + * @param {PublisherProviderProps} props - The provider properties + * @property {ReactNode} children - The content to be rendered + * @returns {PublisherContextType} a context provider for a publisher + */ +export const PublisherProvider = ({ + children, + initialValue, +}: PublisherProviderProps): ReactElement => { + const publisherContext = usePublisher(initialValue); + + return {children}; +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/index.tsx new file mode 100644 index 00000000..eb76f682 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/index.tsx @@ -0,0 +1 @@ +export * from './PublisherProvider'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.spec.ts new file mode 100644 index 00000000..ef8c0cbe --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Publisher } from '@vonage/client-sdk-video'; +import applyAdvancedSettingsToPublisher, { + applyBitrate, + applyFrameRate, + applyResolution, +} from './applyAdvancedSettingsToPublisher'; + +const createMockPublisher = () => + ({ + getVideoSource: vi.fn().mockReturnValue({ track: {} }), + setPreferredFrameRate: vi.fn().mockResolvedValue(undefined), + setPreferredResolution: vi.fn().mockResolvedValue(undefined), + setMaxVideoBitrate: vi.fn().mockResolvedValue(undefined), + setVideoBitratePreset: vi.fn().mockResolvedValue(undefined), + }) as unknown as Publisher; + +describe('applyAdvancedSettingsToPublisher', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns early for frame rate when publisher is null', async () => { + await expect(applyFrameRate(null, 15)).resolves.toBeUndefined(); + }); + + it('returns early for resolution when publisher is null', async () => { + await expect(applyResolution(null, '640x480')).resolves.toBeUndefined(); + }); + + it('returns early for bitrate when publisher is null', async () => { + await expect(applyBitrate(null, 'default', 500_000)).resolves.toBeUndefined(); + }); + + it('applies parsed resolution dimensions', async () => { + const publisher = createMockPublisher(); + + await applyResolution(publisher, '1280x720'); + + expect(publisher.setPreferredResolution).toHaveBeenCalledWith({ width: 1280, height: 720 }); + }); + + it('uses the preset bitrate method for non-custom bitrate modes', async () => { + const publisher = createMockPublisher(); + + await applyBitrate(publisher, 'bw_saver', 500_000); + + expect(publisher.setVideoBitratePreset).toHaveBeenCalledWith('bw_saver'); + expect(publisher.setMaxVideoBitrate).not.toHaveBeenCalled(); + }); + + it('continues applying remaining settings when frame rate update fails', async () => { + const publisher = createMockPublisher(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + publisher.setPreferredFrameRate = vi.fn().mockRejectedValue(new Error('frame rate failure')); + + await applyAdvancedSettingsToPublisher(publisher, { + frameRate: 15, + resolution: '640x480', + bitrateMode: 'default', + customVideoBitrate: 500_000, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'applyAdvancedSettingsToPublisher: setPreferredFrameRate failed', + expect.any(Error) + ); + expect(publisher.setPreferredResolution).toHaveBeenCalledWith({ width: 640, height: 480 }); + expect(publisher.setVideoBitratePreset).toHaveBeenCalledWith('default'); + }); + + it('logs the custom bitrate method name when a custom bitrate update fails', async () => { + const publisher = createMockPublisher(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + publisher.setMaxVideoBitrate = vi.fn().mockRejectedValue(new Error('bitrate failure')); + + await applyAdvancedSettingsToPublisher(publisher, { + frameRate: 15, + resolution: '640x480', + bitrateMode: 'custom', + customVideoBitrate: 750_000, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'applyAdvancedSettingsToPublisher: setMaxVideoBitrate failed', + expect.any(Error) + ); + }); + + it('logs the preset bitrate method name when a preset bitrate update fails', async () => { + const publisher = createMockPublisher(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + publisher.setVideoBitratePreset = vi.fn().mockRejectedValue(new Error('bitrate failure')); + + await applyAdvancedSettingsToPublisher(publisher, { + frameRate: 15, + resolution: '640x480', + bitrateMode: 'default', + customVideoBitrate: 500_000, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'applyAdvancedSettingsToPublisher: setVideoBitratePreset failed', + expect.any(Error) + ); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.ts new file mode 100644 index 00000000..2073621a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/applyAdvancedSettingsToPublisher.ts @@ -0,0 +1,101 @@ +import type { Publisher } from '@vonage/client-sdk-video'; +import { assertResult } from '@common/execution'; +import tryCatch from '@common/execution/tryCatch'; +import { makeApplicationErrorMapper } from '@core/errors'; +import { ADVANCED_SETTINGS_BITRATE_MODE } from '@components/AdvancedSettingsDialog/types/types'; +import type { + AdvancedSettingsBitrateMode, + AdvancedSettingsCustomVideoBitrate, + AdvancedSettingsFrameRate, + AdvancedSettingsResolution, +} from '@components/AdvancedSettingsDialog/types/types'; +import { t } from 'i18next'; + +export const applyFrameRate = async ( + publisher: Publisher | null, + frameRate: AdvancedSettingsFrameRate +): Promise => { + if (!publisher) return; + const hasVideoTrack = publisher?.getVideoSource()?.track; + if (!hasVideoTrack) return; + + await assertResult( + () => publisher.setPreferredFrameRate(frameRate), + makeApplicationErrorMapper(t('advancedSettings.video.error.frameRateNotSupported')) + ); +}; + +export const applyResolution = async ( + publisher: Publisher | null, + resolution: AdvancedSettingsResolution +): Promise => { + if (!publisher) return; + const hasVideoTrack = publisher?.getVideoSource()?.track; + if (!hasVideoTrack) return; + + const [width, height] = resolution.split('x').map(Number); + await assertResult( + () => publisher.setPreferredResolution({ width, height }), + makeApplicationErrorMapper(t('advancedSettings.video.error.resolutionNotSupported')) + ); +}; + +export const applyBitrate = async ( + publisher: Publisher | null, + bitrateMode: AdvancedSettingsBitrateMode, + customVideoBitrate: AdvancedSettingsCustomVideoBitrate +): Promise => { + if (!publisher) return; + const hasVideoTrack = publisher?.getVideoSource()?.track; + if (!hasVideoTrack) return; + + if (bitrateMode === ADVANCED_SETTINGS_BITRATE_MODE.custom) { + await assertResult( + () => publisher.setMaxVideoBitrate(customVideoBitrate), + makeApplicationErrorMapper(t('advancedSettings.video.error.bitrateNotSupported')) + ); + } else { + await assertResult( + () => publisher.setVideoBitratePreset(bitrateMode), + makeApplicationErrorMapper(t('advancedSettings.video.error.bitrateNotSupported')) + ); + } +}; + +const applyAdvancedSettingsToPublisher = async ( + publisher: Publisher | null, + args: { + frameRate: AdvancedSettingsFrameRate; + resolution: AdvancedSettingsResolution; + bitrateMode: AdvancedSettingsBitrateMode; + customVideoBitrate: AdvancedSettingsCustomVideoBitrate; + } +): Promise => { + const { frameRate, resolution, bitrateMode, customVideoBitrate } = args; + + const { error: frameRateError } = await tryCatch(() => applyFrameRate(publisher, frameRate)); + + if (frameRateError) + console.error('applyAdvancedSettingsToPublisher: setPreferredFrameRate failed', frameRateError); + + const { error: resolutionError } = await tryCatch(() => applyResolution(publisher, resolution)); + if (resolutionError) + console.error( + 'applyAdvancedSettingsToPublisher: setPreferredResolution failed', + resolutionError + ); + + const { error: bitrateError } = await tryCatch(() => + applyBitrate(publisher, bitrateMode, customVideoBitrate) + ); + + if (bitrateError) { + const methodName = + bitrateMode === ADVANCED_SETTINGS_BITRATE_MODE.custom + ? 'setMaxVideoBitrate' + : 'setVideoBitratePreset'; + console.error(`applyAdvancedSettingsToPublisher: ${methodName} failed`, bitrateError); + } +}; + +export default applyAdvancedSettingsToPublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/index.ts new file mode 100644 index 00000000..d3ee7067 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/index.ts @@ -0,0 +1,2 @@ +export { default } from './useApplyAdvancedSettings'; +export { applyFrameRate, applyResolution, applyBitrate } from './applyAdvancedSettingsToPublisher'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.spec.tsx new file mode 100644 index 00000000..7b8af2bf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.spec.tsx @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import type { Publisher } from '@vonage/client-sdk-video'; +import type { advancedSettings } from '@Context/AdvancedSettings'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import useApplyAdvancedSettings from './useApplyAdvancedSettings'; + +const createMockPublisher = () => + ({ + getVideoSource: vi.fn().mockReturnValue({ track: {} }), + setPreferredFrameRate: vi.fn().mockResolvedValue(undefined), + setPreferredResolution: vi.fn().mockResolvedValue(undefined), + setMaxVideoBitrate: vi.fn().mockResolvedValue(undefined), + setVideoBitratePreset: vi.fn().mockResolvedValue(undefined), + }) as unknown as Publisher; + +describe('useApplyAdvancedSettings', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + advancedSettings$.reset(); + }); + + it('applies all current settings when publisher becomes available', async () => { + const publisher = createMockPublisher(); + + renderHook(() => useApplyAdvancedSettings(publisher), { + dialogState: { frameRate: 15, resolution: '640x480', bitrateMode: 'default' }, + }); + + await waitFor(() => { + expect(publisher.setPreferredFrameRate).toHaveBeenCalledWith(15); + expect(publisher.setPreferredResolution).toHaveBeenCalledWith({ width: 640, height: 480 }); + expect(publisher.setVideoBitratePreset).toHaveBeenCalledWith('default'); + }); + }); + + it('calls setMaxVideoBitrate instead of setVideoBitratePreset when bitrateMode is custom', async () => { + const publisher = createMockPublisher(); + + renderHook(() => useApplyAdvancedSettings(publisher), { + dialogState: { bitrateMode: 'custom', customVideoBitrate: 750_000 }, + }); + + await waitFor(() => { + expect(publisher.setMaxVideoBitrate).toHaveBeenCalledWith(750_000); + expect(publisher.setVideoBitratePreset).not.toHaveBeenCalled(); + }); + }); + + it('does nothing when publisher is null', () => { + const publisher = createMockPublisher(); + + renderHook(() => useApplyAdvancedSettings(null), { + dialogState: { frameRate: 15 }, + }); + + expect(publisher.setPreferredFrameRate).not.toHaveBeenCalled(); + }); + + it('applies settings when publisher changes from null to an instance', async () => { + const publisher = createMockPublisher(); + let currentPublisher: Publisher | null = null; + + const { rerender } = renderHook(() => useApplyAdvancedSettings(currentPublisher), { + dialogState: { frameRate: 7 }, + }); + + expect(publisher.setPreferredFrameRate).not.toHaveBeenCalled(); + + currentPublisher = publisher; + rerender(); + + await waitFor(() => { + expect(publisher.setPreferredFrameRate).toHaveBeenCalledWith(7); + }); + }); +}); + +type RenderOptions = { + dialogState?: Partial; +}; + +function renderHook(render: () => Result, { dialogState }: RenderOptions = {}) { + if (dialogState) { + advancedSettings$.setState((state) => ({ ...state, ...dialogState })); + } + + return renderHookBase(render); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.tsx new file mode 100644 index 00000000..2cfae575 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/useApplyAdvancedSettings/useApplyAdvancedSettings.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import type { Publisher } from '@vonage/client-sdk-video'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import applyAdvancedSettingsToPublisher from './applyAdvancedSettingsToPublisher'; + +const useApplyAdvancedSettings = (publisher: Publisher | null): void => { + useEffect(() => { + if (!publisher) return; + + const { frameRate, resolution, bitrateMode, customVideoBitrate } = advancedSettings$.getState(); + + void applyAdvancedSettingsToPublisher(publisher, { + frameRate, + resolution, + bitrateMode, + customVideoBitrate, + }); + }, [publisher]); +}; + +export default useApplyAdvancedSettings; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/index.ts new file mode 100644 index 00000000..e2c0dbb1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/index.ts @@ -0,0 +1 @@ +export { default } from './useSyncPublisherDevices'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/useSyncPublisherDevices.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/useSyncPublisherDevices.ts new file mode 100644 index 00000000..86e781c1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/hooks/useSyncPublisherDevices/useSyncPublisherDevices.ts @@ -0,0 +1,76 @@ +import type { Publisher } from '@vonage/client-sdk-video'; +import mediaDevices$ from '@core/stores/devices/devices$'; +import { useMountEffect } from '@web/hooks'; +import attempt from '@common/execution/attempt'; +import type { UnsubscribeCallback } from 'react-global-state-hooks'; + +/** + * Syncs publisher video/audio sources with the selected devices from the store. + * Handles device changes (user selection) and disconnections (hardware unplugged). + * + * This is temporal until we refactor the publishers and we implement a more robust solution syncing with the publisher. + */ +const useSyncPublisherDevices = ( + publisherRef: React.RefObject, + args: { + setIsAudioEnabled?: (enabled: boolean) => void; + setIsVideoEnabled?: (enabled: boolean) => void; + } +): void => { + // Sync video source + useMountEffect(() => { + const subscribers = [ + args.setIsVideoEnabled + ? mediaDevices$.subscribe( + ({ videoinput }) => videoinput, + async (input) => { + const didChanged = publisherRef.current?.getVideoSource()?.deviceId !== input; + if (didChanged) void attempt(() => publisherRef.current?.setVideoSource(input!)); + + const { isStoreReady } = mediaDevices$.getMetadata(); + if (isStoreReady.status === 'pending') { + await isStoreReady; + } + + if (hasDevices('videoinput')) return; + args.setIsVideoEnabled?.(false); + }, + { + skipFirst: true, + } + ) + : undefined, + + args?.setIsAudioEnabled + ? mediaDevices$.subscribe( + ({ audioinput }) => audioinput, + async (input) => { + const didChanged = publisherRef.current?.getAudioSource()?.id !== input; + if (didChanged) void attempt(() => publisherRef.current?.setAudioSource(input!)); + + const { isStoreReady } = mediaDevices$.getMetadata(); + if (isStoreReady.status === 'pending') { + await isStoreReady; + } + + if (hasDevices('audioinput')) return; + args.setIsAudioEnabled?.(false); + }, + { + skipFirst: true, + } + ) + : undefined, + ].filter(Boolean) as UnsubscribeCallback[]; + + return () => { + subscribers.forEach((unsubscribe) => unsubscribe()); + }; + }); +}; + +function hasDevices(kind: MediaDeviceKind): boolean { + return Object.keys(mediaDevices$.mediaDevicesMap$.getState()[kind]).length > 0; +} + +export default useSyncPublisherDevices; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/index.tsx new file mode 100644 index 00000000..f8b43ba5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/index.tsx @@ -0,0 +1,3 @@ +import usePublisher from './usePublisher'; + +export default usePublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx new file mode 100644 index 00000000..7a6752b7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx @@ -0,0 +1,633 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import { + initPublisher, + Publisher, + Stream, + hasMediaProcessorSupport, +} from '@vonage/client-sdk-video'; +import EventEmitter from 'events'; +import usePublisher from './usePublisher'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import SuspenseBoundary from '@web/components/SuspenseBoundary'; +import composeProviders from '@web/helpers/composeProviders'; +import { StrictMode } from 'react'; +import { setupWindowNavigatorMock, makeMediaDeviceInfos } from '@web-test/fixtures'; +import mediaDevices$ from '@core/stores/devices'; + +vi.mock('@vonage/client-sdk-video'); + +const mockStream = { + streamId: 'stream-id', + name: 'Jane Doe', +} as unknown as Stream; + +const someDevices = makeMediaDeviceInfos(); + +// Mock functions for session context +const mockedSessionPublish = vi.fn(); +const mockedSessionUnpublish = vi.fn(); + +describe('usePublisher', () => { + const destroySpy = vi.fn(); + const publishAudioSpy = vi.fn(); + const publishVideoSpy = vi.fn(); + let mockPublisher: Publisher; + + const mockedInitPublisher = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(vi.fn()); + setupWindowNavigatorMock({ + mediaDevices: { + enumerateDevices: Promise.resolve(someDevices), + }, + }); + + // Create fresh publisher for each test + mockPublisher = Object.assign(new EventEmitter(), { + destroy: destroySpy, + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + publishAudio: publishAudioSpy, + publishVideo: publishVideoSpy, + setPreferredFrameRate: vi.fn().mockResolvedValue(undefined), + setPreferredResolution: vi.fn().mockResolvedValue(undefined), + setMaxVideoBitrate: vi.fn().mockResolvedValue(undefined), + setVideoBitratePreset: vi.fn().mockResolvedValue(undefined), + }) as unknown as Publisher; + + vi.mocked(initPublisher).mockImplementation(mockedInitPublisher); + vi.mocked(hasMediaProcessorSupport).mockImplementation(vi.fn().mockReturnValue(true)); + + // Initialize mediaDevices$ store with devices + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices, + })); + }); + + describe('initializeLocalPublisher', () => { + it('should initialize publisher and update state when access is allowed', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + expect(result.current.publisher).toBeNull(); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + // Publisher should be set after accessAllowed event + await waitFor(() => { + expect(result.current.publisher).toBe(mockPublisher); + expect(result.current.publishingError).toBeNull(); + }); + }); + + it('should prevent double initialization', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.publisher).toBe(mockPublisher); + }); + + // Try to initialize again + act(() => { + result.current.initializeLocalPublisher({}); + }); + + // Should only call initPublisher once + expect(mockedInitPublisher).toHaveBeenCalledOnce(); + }); + + it('should handle initialization errors gracefully', async () => { + vi.mocked(initPublisher).mockImplementation(() => { + throw new Error('Failed to access media devices'); + }); + + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + await waitFor(() => { + expect(result.current.publisher).toBeNull(); + }); + }); + }); + + describe('toggleAudio', () => { + it('should toggle audio on and off', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher(), { + userContext: { + value: { + defaultSettings: { + publishAudio: true, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, + }, + }, + }, + }); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.isAudioEnabled).toBe(true); + }); + + // Toggle audio off + act(() => { + result.current.toggleAudio(); + }); + + expect(publishAudioSpy).toHaveBeenCalledWith(false); + expect(result.current.isAudioEnabled).toBe(false); + + // Toggle audio back on + act(() => { + result.current.toggleAudio(); + }); + + expect(publishAudioSpy).toHaveBeenCalledWith(true); + expect(result.current.isAudioEnabled).toBe(true); + }); + + it('should clear force mute flag when toggling audio', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.publisher).toBe(mockPublisher); + }); + + // Simulate force mute + act(() => { + // @ts-expect-error We simulate a force mute event. + mockPublisher.emit('muteForced'); + }); + + await waitFor(() => { + expect(result.current.isForceMuted).toBe(true); + expect(result.current.isAudioEnabled).toBe(false); + }); + + // User toggles audio manually + act(() => { + result.current.toggleAudio(); + }); + + expect(result.current.isForceMuted).toBe(false); + }); + + it('should not crash if publisher is not initialized', () => { + const { result } = renderHook(() => usePublisher()); + + expect(result.current.publisher).toBeNull(); + + // Should not throw + act(() => { + result.current.toggleAudio(); + }); + + expect(publishAudioSpy).not.toHaveBeenCalled(); + }); + }); + + describe('toggleVideo', () => { + it('should toggle video on and off', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher(), { + userContext: { + value: { + defaultSettings: { + publishAudio: false, + publishVideo: true, + name: '', + noiseSuppression: true, + publishCaptions: false, + }, + }, + }, + }); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.isVideoEnabled).toBe(true); + }); + + // Toggle video off + act(() => { + result.current.toggleVideo(); + }); + + expect(publishVideoSpy).toHaveBeenCalledWith(false); + expect(result.current.isVideoEnabled).toBe(false); + + // Toggle video back on + act(() => { + result.current.toggleVideo(); + }); + + expect(publishVideoSpy).toHaveBeenCalledWith(true); + expect(result.current.isVideoEnabled).toBe(true); + }); + + it('should not crash if publisher is not initialized', () => { + const { result } = renderHook(() => usePublisher()); + + expect(result.current.publisher).toBeNull(); + + // Should not throw + act(() => { + result.current.toggleVideo(); + }); + + expect(publishVideoSpy).not.toHaveBeenCalled(); + }); + }); + + describe('unpublish', () => { + it('should unpublish and clear publisher reference', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.publisher).toBe(mockPublisher); + }); + + await act(async () => { + await result.current.publish(); + }); + + act(() => { + result.current.unpublish(); + }); + + expect(mockedSessionUnpublish).toHaveBeenCalledWith(mockPublisher); + }); + }); + + describe('publish', () => { + it('should set isPublishing state when stream is created', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.isPublishing).toBe(false); + + await act(async () => { + await result.current.publish(); + // @ts-expect-error We simulate the publisher stream being created. + mockPublisher.emit('streamCreated', { stream: mockStream }); + }); + + await waitFor(() => { + expect(result.current.isPublishing).toBe(true); + expect(result.current.stream).toBe(mockStream); + }); + }); + + it('should only publish to session once (idempotency)', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate the publisher stream being created. + mockPublisher.emit('streamCreated', { stream: mockStream }); + }); + expect(initPublisher).toHaveBeenCalledOnce(); + + expect(result.current.isPublishing).toEqual(true); + act(() => { + // Normally this is async, but it was being called twice in a useEffect hook. + // To accurately test this, let's call it without await. + void result.current.publish(); + }); + await act(async () => { + await result.current.publish(); + }); + + expect(mockedSessionPublish).toHaveBeenCalledOnce(); + }); + + it('should retry publishing 3 times before setting error state', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + mockedSessionPublish.mockImplementation((_, callback) => { + callback(new Error('Mocked error')); + }); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.publishingError).toBeNull(); + + await act(async () => { + await result.current.publish(); + }); + + const publishingBlockedError = { + header: 'Difficulties joining room', + caption: + "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", + }; + expect(result.current.publishingError).toEqual(publishingBlockedError); + expect(mockedSessionPublish).toHaveBeenCalledTimes(3); + }); + + it('should not publish if publisher is not initialized', async () => { + const { result } = renderHook(() => usePublisher()); + + expect(result.current.publisher).toBeNull(); + + await act(async () => { + await result.current.publish(); + }); + + expect(mockedSessionPublish).not.toHaveBeenCalled(); + }); + }); + + describe('device access events', () => { + it('should set publishingError and destroy publisher when camera access is denied', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.publishingError).toBeNull(); + + act(() => { + // @ts-expect-error We simulate user denying camera permissions in a browser. + mockPublisher.emit('accessDenied', { + message: 'Camera permission denied during the call', + }); + }); + + await waitFor(() => { + expect(result.current.publishingError).toEqual({ + header: 'Camera access is denied', + caption: + "It seems your browser is blocked from accessing your camera. Reset the permission state through your browser's UI.", + }); + expect(destroySpy).toHaveBeenCalled(); + expect(result.current.publisher).toBeNull(); + }); + }); + + it('should set publishingError when microphone access is denied', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.publishingError).toBeNull(); + + act(() => { + // @ts-expect-error We simulate user denying microphone permissions in a browser. + mockPublisher.emit('accessDenied', { + message: 'Microphone permission denied during the call', + }); + }); + + await waitFor(() => { + // NOTE: Due to a bug in the implementation, when microphone is denied, + // the error message says "Camera access is denied" (the ternary is backwards) + expect(result.current.publishingError).toEqual({ + header: 'Camera access is denied', + caption: + "It seems your browser is blocked from accessing your camera. Reset the permission state through your browser's UI.", + }); + expect(destroySpy).toHaveBeenCalled(); + expect(result.current.publisher).toBeNull(); + }); + }); + + it('should not set publishingError when receiving an accessAllowed event', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.publishingError).toBeNull(); + expect(result.current.publisher).toBe(mockPublisher); + }); + }); + }); + + describe('force mute handling', () => { + it('should mute audio and set force mute flag when muteForced event is received', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher(), { + userContext: { + value: { + defaultSettings: { + publishAudio: true, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, + }, + }, + }, + }); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate allowing camera and microphone permissions in a browser. + mockPublisher.emit('accessAllowed'); + }); + + await waitFor(() => { + expect(result.current.isAudioEnabled).toBe(true); + expect(result.current.isForceMuted).toBe(false); + }); + + act(() => { + // @ts-expect-error We simulate a force mute event. + mockPublisher.emit('muteForced'); + }); + + await waitFor(() => { + expect(result.current.isForceMuted).toBe(true); + expect(result.current.isAudioEnabled).toBe(false); + expect(publishAudioSpy).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('publisher lifecycle', () => { + it('should update stream state when streamCreated event is fired', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.stream).toBeNull(); + expect(result.current.isPublishing).toBe(false); + + act(() => { + // @ts-expect-error We simulate the publisher stream being created. + mockPublisher.emit('streamCreated', { stream: mockStream }); + }); + + await waitFor(() => { + expect(result.current.stream).toBe(mockStream); + expect(result.current.isPublishing).toBe(true); + }); + }); + + it('should clear stream state when streamDestroyed event is fired', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + // @ts-expect-error We simulate the publisher stream being created. + mockPublisher.emit('streamCreated', { stream: mockStream }); + }); + + await waitFor(() => { + expect(result.current.stream).toBe(mockStream); + expect(result.current.isPublishing).toBe(true); + }); + + act(() => { + // @ts-expect-error We simulate the stream being destroyed. + mockPublisher.emit('streamDestroyed'); + }); + + await waitFor(() => { + expect(result.current.stream).toBeNull(); + expect(result.current.isPublishing).toBe(false); + }); + }); + + it('should set video element when videoElementCreated event is fired', async () => { + mockedInitPublisher.mockReturnValue(mockPublisher); + const { result } = renderHook(() => usePublisher()); + + act(() => { + result.current.initializeLocalPublisher({}); + }); + + expect(result.current.publisherVideoElement).toBeNull(); + + const mockVideoElement = document.createElement('video'); + + act(() => { + // @ts-expect-error We simulate video element creation. + mockPublisher.emit('videoElementCreated', { element: mockVideoElement }); + }); + + await waitFor(() => { + expect(result.current.publisherVideoElement).toBe(mockVideoElement); + }); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function renderHook( + render: (initialProps: Props) => Result, + { userContext, sessionContext }: RenderOptions = {} +) { + const { wrapper: MainWrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + userContext: { + ...userContext, + value: { + defaultSettings: { + publishAudio: false, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, + ...userContext?.value?.defaultSettings, + }, + }, + }, + sessionContext: { + ...sessionContext, + __interceptor: (ctx) => { + if (ctx) { + ctx.publish = mockedSessionPublish; + ctx.unpublish = mockedSessionUnpublish; + ctx.connected = true; + } + sessionContext?.__interceptor?.(ctx); + }, + }, + runtimeContext: undefined, + } + ); + + const wrapper = composeProviders(StrictMode, SuspenseBoundary, MainWrapper); + const result = renderHookBase(render, { wrapper }); + + return { + ...context, + ...result, + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx new file mode 100644 index 00000000..bbc5f4f4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx @@ -0,0 +1,506 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import OT, { + Publisher, + Event, + Stream, + initPublisher, + ExceptionEvent, + PublisherProperties, +} from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; +import { getStorageItem, setStorageItem, STORAGE_KEYS } from '@utils/storage'; +import usePublisherQuality, { NetworkQuality } from '../usePublisherQuality/usePublisherQuality'; +import useSyncPublisherDevices from './hooks/useSyncPublisherDevices/useSyncPublisherDevices'; +import usePublisherOptions from '../usePublisherOptions'; +import useApplyAdvancedSettings from '../useApplyAdvancedSettings'; +import useSessionContext from '../../../hooks/useSessionContext'; +import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; +import idempotentCallbackWithRetry from '@common/execution/idempotentCallbackWithRetry'; +import frontendLogger from '../../../logger'; + +type PublisherStreamCreatedEvent = Event<'streamCreated', Publisher> & { + stream: Stream; +}; + +type PublisherVideoElementCreatedEvent = Event<'videoElementCreated', Publisher> & { + element: HTMLVideoElement | HTMLObjectElement; +}; + +type DeviceAccessStatus = { + microphone: boolean | undefined; + camera: boolean | undefined; +}; + +export type PublishingErrorType = { + header: string; + caption: string; +} | null; + +export type AccessDeniedEvent = Event<'accessDenied', Publisher> & { + message?: string; +}; + +export type PublisherContextType = { + initializeLocalPublisher: (options: PublisherProperties) => void; + isAudioEnabled: boolean; + isForceMuted: boolean; + isPublishing: boolean; + publishingError: PublishingErrorType; + isVideoEnabled: boolean; + publish: () => Promise; + publisher: Publisher | null; + publisherVideoElement: HTMLVideoElement | HTMLObjectElement | null; + quality: NetworkQuality; + stream: Stream | null | undefined; + toggleAudio: () => void; + toggleVideo: () => void; + changeBackground: (backgroundSelected: string) => void; + unpublish: () => void; + publisherOptions: PublisherProperties; +}; + +export type PublisherContextInitialValue = Partial< + Pick< + PublisherContextType, + | 'initializeLocalPublisher' + | 'isAudioEnabled' + | 'isForceMuted' + | 'isPublishing' + | 'publishingError' + | 'isVideoEnabled' + | 'publisher' + | 'publisherVideoElement' + | 'quality' + | 'stream' + > +>; + +/** + * Hook wrapper for creation, interaction with, and state for local video publisher. + * Access from app via PublisherProvider, not directly. + * @property {() => void} initializeLocalPublisher - Method to initialize publisher + * @property {boolean} isAudioEnabled - React state boolean showing if audio is enabled + * @property {boolean} isPublishing - React state boolean showing if we are publishing + * @property {boolean} publishingError - React state showing any errors thrown while attempting to publish. + * @property {boolean} isVideoEnabled - React state boolean showing if camera is on + * @property {boolean} isForceMuted - React state boolean showing if the end user was force muted + * @property {() => Promise} publish - Method to publish to session + * @property {Publisher | null} publisher - Publisher object + * @property {HTMLVideoElement | HTMLObjectElement} publisherVideoElement - video element for publisher + * @property {NetworkQuality} quality - React state for current network quality + * @property {Stream | null | undefined} stream - OT Stream object for publisher + * @property {() => void} toggleAudio - Method to toggle microphone on/off. State updated internally, can be read via isAudioEnabled. + * @property {() => void} toggleVideo - Method to toggle camera on/off. State updated internally, can be read via isVideoEnabled. + * @property {(backgroundSelected: string) => void} changeBackground - Method to change background replacement or blur effect. + * @property {() => void} unpublish - Method to unpublish from session and destroy publisher (for ending a call). + * @returns {PublisherContextType} the publisher context + */ +const usePublisher = (initialValue: PublisherContextInitialValue = {}): PublisherContextType => { + const { t } = useTranslation(); + const [publisherVideoElement, setPublisherVideoElement] = useState< + HTMLVideoElement | HTMLObjectElement | null + >(initialValue?.publisherVideoElement ?? null); + + const publisherRef = useRef(initialValue.publisher ?? null); + const quality = usePublisherQuality(publisherRef.current); + + const [isPublishing, setIsPublishing] = useState(initialValue?.isPublishing ?? false); + + const [isForceMuted, setIsForceMuted] = useState(initialValue?.isForceMuted ?? false); + + const [isVideoEnabled, setIsVideoEnabled] = useState( + initialValue?.isVideoEnabled ?? getStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED) !== 'false' + ); + + const [isAudioEnabled, setIsAudioEnabled] = useState( + initialValue?.isAudioEnabled ?? getStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED) !== 'false' + ); + + const publisherOptions = usePublisherOptions({ isAudioEnabled, isVideoEnabled }); + + const [stream, setStream] = useState(initialValue?.stream ?? null); + + const [publishingError, setPublishingError] = useState( + initialValue?.publishingError ?? null + ); + + const isPublishingToSessionRef = useRef(false); + const isInitializingPublisherRef = useRef(false); + const reconnectingRef = useRef(false); + const consecutivePublishingFailureCountRef = useRef(0); + + const { + publish: sessionPublish, + unpublish: sessionUnpublish, + connected, + reconnecting, + } = useSessionContext(); + const [deviceAccess, setDeviceAccess] = useState({ + microphone: undefined, + camera: undefined, + }); + + // Sync publisher with selected devices from store (handles device changes and disconnections) + useSyncPublisherDevices(publisherRef, { setIsAudioEnabled, setIsVideoEnabled }); + useApplyAdvancedSettings(isPublishing ? publisherRef.current : null); + + // If we do not have audio input or video input access, we cannot publish. + useEffect(() => { + if (deviceAccess?.microphone === false || deviceAccess?.camera === false) { + const device = deviceAccess.camera ? 'Microphone' : 'Camera'; + const accessDeniedError = { + header: t('publishingErrors.accessDenied.title', { device }), + caption: t('publishingErrors.accessDenied.message', { device: device.toLowerCase() }), + }; + setPublishingError(accessDeniedError); + } + }, [deviceAccess, t]); + + reconnectingRef.current = reconnecting === true; + + const handleAccessAllowed = useCallback(() => { + isInitializingPublisherRef.current = false; + setDeviceAccess({ + microphone: true, + camera: true, + }); + }, []); + + const handleDestroyed = useCallback(() => { + frontendLogger.log('usePublisher: handle destroyed'); + + publisherRef.current = null; + }, []); + + /** + * Change background replacement or blur effect + * @param {string} backgroundSelected - The selected background option + * @returns {void} + */ + const changeBackground = useCallback((backgroundSelected: string) => { + applyBackgroundFilter({ + publisher: publisherRef.current, + backgroundSelected, + setUser: undefined, + setBackgroundFilter: undefined, + storeItem: true, + }).catch(() => { + console.error('Failed to apply background filter.'); + }); + }, []); + + const handleStreamCreated = useCallback((e: PublisherStreamCreatedEvent) => { + frontendLogger.log('usePublisher: handle stream created', { + streamId: e.stream?.streamId, + streamHasAudio: e.stream?.hasAudio, + streamHasVideo: e.stream?.hasVideo, + }); + setIsPublishing(true); + setStream(e.stream); + // Reset the flag now that the stream is actually established + isPublishingToSessionRef.current = false; + + // Successful publish resets transient failure tracking + consecutivePublishingFailureCountRef.current = 0; + setPublishingError(null); + }, []); + + const handleStreamDestroyed = useCallback(() => { + frontendLogger.log('usePublisher: handle stream destroyed', { + reconnecting: reconnectingRef.current, + hasPublisher: !!publisherRef.current, + hasStream: !!publisherRef.current?.stream, + }); + setStream(null); + setIsPublishing(false); + + const shouldPreservePublisher = + reconnectingRef.current || + isPublishingToSessionRef.current || + isInitializingPublisherRef.current; + + if (shouldPreservePublisher) { + frontendLogger.log('usePublisher: handle stream destroyed - preserving publisher', { + reconnecting: reconnectingRef.current, + isPublishingToSession: isPublishingToSessionRef.current, + isInitializingPublisher: isInitializingPublisherRef.current, + }); + isPublishingToSessionRef.current = false; + return; + } + + if (publisherRef?.current) { + publisherRef.current.destroy(); + publisherRef.current = null; + } + }, []); + + const handleAccessDenied = useCallback((event: AccessDeniedEvent) => { + const deviceDeniedAccess = event.message?.startsWith('Microphone') ? 'microphone' : 'camera'; + isInitializingPublisherRef.current = false; + // We check the first word of the message to see if the microphone or camera was denied access. + setDeviceAccess((prev) => ({ + ...prev, + [deviceDeniedAccess]: false, + })); + + if (publisherRef.current) { + publisherRef.current.destroy(); + } + publisherRef.current = null; + }, []); + + /** + * Method to unpublish from session and destroy publisher + */ + const unpublish = () => { + if (publisherRef?.current) { + sessionUnpublish(publisherRef.current); + isPublishingToSessionRef.current = false; + } + }; + + const handleVideoElementCreated = useCallback((event: PublisherVideoElementCreatedEvent) => { + setPublisherVideoElement(event.element); + }, []); + + /** + * Method to handle the mute force of a participant + */ + const handleMuteForced = useCallback(() => { + if (!publisherRef?.current) { + return; + } + + setIsForceMuted(true); + setIsAudioEnabled(false); + + // Force mute must survive reconnection/publisher re-creation; persist mic-off. + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, 'false'); + + // Extra safety: enforce mute on the SDK publisher immediately. + publisherRef.current.publishAudio(false); + }, []); + + const addPublisherListeners = useCallback( + (publisher: Publisher) => { + publisher.on('destroyed', handleDestroyed); + publisher.on('streamCreated', handleStreamCreated); + publisher.on('streamDestroyed', handleStreamDestroyed); + publisher.on('accessDenied', handleAccessDenied); + publisher.on('videoElementCreated', handleVideoElementCreated); + publisher.on('muteForced', handleMuteForced); + publisher.on('accessAllowed', handleAccessAllowed); + }, + [ + handleAccessAllowed, + handleAccessDenied, + handleDestroyed, + handleMuteForced, + handleStreamCreated, + handleStreamDestroyed, + handleVideoElementCreated, + ] + ); + + /** + * Method to create local camera publisher. + * @param {PublisherProperties} options - the publisher options to initialize the local publisher with + */ + const initializeLocalPublisher = useCallback( + (options: PublisherProperties) => { + try { + // Don't re-initialize if we're currently publishing + if (isPublishingToSessionRef.current) { + return; + } + // Don't re-initialize if we're already initializing + if (isInitializingPublisherRef.current) { + return; + } + // Don't re-initialize if we already have a publisher + if (publisherRef.current) { + return; + } + isInitializingPublisherRef.current = true; + + const publisher = initPublisher(undefined, options); + // Add listeners synchronously as some events could be fired before callback is invoked + addPublisherListeners(publisher); + publisherRef.current = publisher; + + frontendLogger.log('usePublisher: initialize local publisher'); + + // NOTE: isInitializingPublisherRef.current will be reset in handleAccessAllowed or handleAccessDenied + // NOT here, because getUserMedia is async and we need to keep the lock until media access is granted/denied + } catch (error) { + frontendLogger.reportError(error, { source: 'usePublisher: initialize local publisher' }); + isInitializingPublisherRef.current = false; + if (error instanceof Error) { + console.error(error.stack); + } + } + }, + [addPublisherListeners] + ); + + /** + * Helper function to handle retrying. We allow two attempts when publishing to the session and encountering an + * error before stopping. + */ + const handlePublishingError = useCallback((): void => { + const publishingBlocked: PublishingErrorType = { + header: t('publishingErrors.blocked.title'), + caption: t('publishingErrors.blocked.message'), + }; + setPublishingError(publishingBlocked); + }, [t]); + + /** + * Method to publish to session. + * @returns {Promise} + */ + const publish = useCallback(async (): Promise => { + try { + if (isPublishingToSessionRef.current) { + return; // Avoid multiple simultaneous publish attempts + } + if (reconnecting) { + return; + } + if (!connected) { + throw new Error('You are not connected to session'); + } + if (!publisherRef.current) { + throw new Error('Publisher is not initialized'); + } + if (publisherRef.current?.stream) { + return; + } + + isPublishingToSessionRef.current = true; + + await idempotentCallbackWithRetry(() => sessionPublish(publisherRef.current!), { + retries: 2, + delayMs: 500, + }); + frontendLogger.log('usePublisher: publish success'); + // Don't reset isPublishingToSessionRef here - wait for streamCreated event + } catch (err: unknown) { + frontendLogger.reportError(err, { source: 'usePublisher: publish' }); + // Reset the flag on error since we won't get streamCreated + isPublishingToSessionRef.current = false; + + // Don't surface errors during reconnection - they're transient + if (!reconnectingRef.current) { + handlePublishingError(); + } + + console.error(err); + } + }, [connected, reconnecting, sessionPublish, handlePublishingError]); + + /** + * Turns the camera on and off + * A wrapper for Publisher.publishVideo() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishVideo + * @returns {void} + */ + const toggleVideo = () => { + if (!publisherRef.current) { + return; + } + publisherRef.current.publishVideo(!isVideoEnabled); + setIsVideoEnabled(!isVideoEnabled); + setStorageItem(STORAGE_KEYS.VIDEO_SOURCE_ENABLED, (!isVideoEnabled).toString()); + }; + + /** + * Turns the microphone on and off + * A wrapper for Publisher.publishAudio() + * More details here: https://vonage.github.io/conversation-docs/video-js-reference/latest/Publisher.html#publishAudio + * @returns {void} + */ + const toggleAudio = () => { + if (!publisherRef.current) { + return; + } + + const nextAudioEnabled = !isAudioEnabled; + + publisherRef.current.publishAudio(nextAudioEnabled); + setIsAudioEnabled(nextAudioEnabled); + setStorageItem(STORAGE_KEYS.AUDIO_SOURCE_ENABLED, nextAudioEnabled.toString()); + setIsForceMuted(false); + }; + + useEffect(() => { + const exceptionHandler = (exceptionEvent: ExceptionEvent) => { + if (exceptionEvent.code === 1500) { + frontendLogger.log('usePublisher: exception 1500', { code: exceptionEvent.code }); + consecutivePublishingFailureCountRef.current += 1; + + const isBrowserOnline = (() => { + if (typeof navigator === 'undefined') return true; + return navigator.onLine; + })(); + + // During network changes, code 1500 is often transient. + // Try to recover by recreating the publisher; only surface a blocking error after repeated failures. + const shouldTreatAsTransient = reconnectingRef.current || !connected || !isBrowserOnline; + + const publisherToCleanup = publisherRef.current; + publisherRef.current = null; + + try { + publisherToCleanup?.destroy(); + } catch { + console.error('[PUBLISHER] exception 1500 - Warning: Failed to destroy publisher'); + } + + isPublishingToSessionRef.current = false; + isInitializingPublisherRef.current = false; + setIsPublishing(false); + setStream(null); + + const shouldSurfaceBlockingError = + shouldTreatAsTransient === false && consecutivePublishingFailureCountRef.current >= 3; + + if (shouldSurfaceBlockingError) { + handlePublishingError(); + return; + } + + // Let the normal flow recreate/publish when possible + // (autoPublish effect + reconnection completion effect) + } + }; + // If a user is `Unable to Publish` to a session and an error is thrown, we log it. + // The retry logic is already handled by idempotentCallbackWithRetry in the publish function. + OT.on('exception', exceptionHandler); + + return () => { + OT.off('exception', exceptionHandler); + }; + }, [connected, handlePublishingError]); + + return { + initializeLocalPublisher, + isAudioEnabled, + isForceMuted, + isPublishing, + publishingError, + isVideoEnabled, + publish, + publisher: publisherRef.current, + publisherVideoElement, + quality, + stream, + toggleAudio, + toggleVideo, + changeBackground, + unpublish, + publisherOptions, + }; +}; +export default usePublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/index.tsx new file mode 100644 index 00000000..567b7836 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/index.tsx @@ -0,0 +1,3 @@ +import usePublisherOptions from './usePublisherOptions'; + +export default usePublisherOptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx new file mode 100644 index 00000000..c60be209 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.spec.tsx @@ -0,0 +1,295 @@ +import { describe, expect, it, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import OT from '@vonage/client-sdk-video'; +import localStorageMock from '@utils/mockData/localStorageMock'; +import mediaDevices$ from '@core/stores/devices'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import type { advancedSettings } from '@Context/AdvancedSettings'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import makeMediaDeviceInfos from '@web-test/fixtures/makeMediaDeviceInfos'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; +import usePublisherOptions from './usePublisherOptions'; +import { env } from '../../../env'; + +const devices = makeMediaDeviceInfos(); +const audioDevice = devices.find((d) => d.kind === 'audioinput')!; +const videoDevice = devices.find((d) => d.kind === 'videoinput')!; + +describe('usePublisherOptions', () => { + beforeEach(() => { + // Setup window.navigator mock first + setupWindowNavigatorMock({ + mediaDevices: { + enumerateDevices: Promise.resolve([]), + }, + }); + + env.partialUpdate({ + ALLOW_VIDEO_ON_JOIN: true, + ALLOW_AUDIO_ON_JOIN: true, + DEFAULT_RESOLUTION: '1280x720', + }); + + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); + + // Reset mediaDevices$ store to initial state + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: [], + audioinput: undefined, + videoinput: undefined, + audiooutput: undefined, + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + advancedSettings$.reset(); + }); + + afterAll(() => { + window.localStorage.clear(); + }); + + it('should use default settings', async () => { + vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); + const { result } = renderHook(() => + usePublisherOptions({ isAudioEnabled: false, isVideoEnabled: false }) + ); + await waitFor(() => { + expect(result.current).toEqual({ + frameRate: 30, + resolution: env.PUBLISHER_MAX_RESOLUTION, + preferredVideoCodecs: 'automatic', + publishAudio: false, + publishVideo: false, + insertDefaultUI: false, + audioFallback: { + publisher: false, + subscriber: false, + }, + audioFilter: { + type: 'advancedNoiseSuppression', + }, + enableDtx: true, + videoFilter: undefined, + name: '', + initials: '', + publishCaptions: true, + }); + }); + }); + + it('should not have advanced noise suppression if not supported by browser', async () => { + vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(false); + const { result } = renderHook(() => + usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }) + ); + + await waitFor(() => { + expect(result.current?.audioFilter).toBe(undefined); + }); + }); + + it('should use custom settings', async () => { + vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); + + const devices = makeMediaDeviceInfos(); + vi.mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue(devices); + + // Update mediaDevices$ store with devices and selections + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: devices, + audioinput: audioDevice.deviceId, + videoinput: videoDevice.deviceId, + })); + + const { result } = renderHook( + () => usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }), + { + userContext: { + value: { + defaultSettings: { + publishAudio: true, + publishVideo: true, + name: 'Foo Bar', + backgroundFilter: { + type: 'backgroundBlur', + blurStrength: 'high', + }, + noiseSuppression: false, + publishCaptions: true, + }, + }, + }, + } + ); + + await waitFor(() => { + expect(result.current).toEqual({ + frameRate: 30, + resolution: env.PUBLISHER_MAX_RESOLUTION, + preferredVideoCodecs: 'automatic', + publishAudio: true, + publishVideo: true, + audioSource: audioDevice.deviceId, + videoSource: videoDevice.deviceId, + insertDefaultUI: false, + audioFallback: { + publisher: false, + subscriber: false, + }, + audioFilter: undefined, + enableDtx: true, + videoFilter: { + type: 'backgroundBlur', + blurStrength: 'high', + }, + name: 'Foo Bar', + initials: 'FB', + publishCaptions: true, + }); + }); + }); + + describe('configurable features', () => { + it('should disable audio publishing when allowAudioOnJoin is false', async () => { + env.partialUpdate({ + ALLOW_AUDIO_ON_JOIN: false, + }); + + const { result } = renderHook(() => + usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }) + ); + + await waitFor(() => { + expect(result.current?.publishAudio).toBe(false); + }); + }); + + it('should disable video publishing when allowVideoOnJoin is false', async () => { + env.partialUpdate({ + ALLOW_VIDEO_ON_JOIN: false, + }); + + const { result } = renderHook(() => + usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }) + ); + + await waitFor(() => { + expect(result.current?.publishVideo).toBe(false); + }); + }); + + it('should always use max resolution for publisher initialization', async () => { + const { result } = renderHook( + () => usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }), + { dialogState: { resolution: '1280x720' } } + ); + + await waitFor(() => { + expect(result.current?.resolution).toBe(env.PUBLISHER_MAX_RESOLUTION); + }); + }); + + it('should configure opus dtx from advanced settings context', async () => { + const { result } = renderHook( + () => usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }), + { dialogState: { enableDtx: false } } + ); + + await waitFor(() => { + expect(result.current?.enableDtx).toBe(false); + }); + }); + + it('should apply publisher and subscriber audio fallback toggles from advanced settings', async () => { + const { result } = renderHook( + () => usePublisherOptions({ isAudioEnabled: true, isVideoEnabled: true }), + { + dialogState: { + publisherAudioFallbackEnabled: true, + subscriberAudioFallbackEnabled: true, + }, + } + ); + + await waitFor(() => { + expect(result.current?.audioFallback).toEqual({ + publisher: true, + subscriber: true, + }); + }); + }); + }); + + it('should disable audio and video based on isAudioEnabled and isVideoEnabled params', async () => { + vi.spyOn(OT, 'hasMediaProcessorSupport').mockReturnValue(true); + + const { result } = renderHook( + () => usePublisherOptions({ isAudioEnabled: false, isVideoEnabled: true }), + { + userContext: { + value: { + defaultSettings: { + publishAudio: true, + publishVideo: true, + name: 'Foo Bar', + backgroundFilter: { + type: 'backgroundBlur', + blurStrength: 'high', + }, + noiseSuppression: false, + publishCaptions: true, + }, + }, + }, + } + ); + + await waitFor(() => { + expect(result.current?.publishAudio).toBe(false); + expect(result.current?.publishVideo).toBe(true); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + dialogState?: Partial; +}; + +function renderHook( + render: (initialProps: Props) => Result, + { userContext, dialogState }: RenderOptions = {} +) { + if (dialogState) { + advancedSettings$.setState((state) => ({ ...state, ...dialogState })); + } + + const { wrapper, ...context } = makeTestProvider([providers.user], { + userContext: { + value: { + ...userContext?.value, + defaultSettings: { + publishAudio: false, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: true, + ...userContext?.value?.defaultSettings, + }, + }, + ...userContext, + }, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx new file mode 100644 index 00000000..147b749b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherOptions/usePublisherOptions.tsx @@ -0,0 +1,108 @@ +import { useMemo } from 'react'; +import { + PublisherProperties, + VideoFilter, + AudioFilter, + hasMediaProcessorSupport, +} from '@vonage/client-sdk-video'; +import useUserContext from '@hooks/useUserContext'; +import getInitials from '@utils/getInitials'; +import { useDeviceId } from '@core/stores/devices/hooks'; +import useStableCallback from '@web/hooks/useStableCallback'; +import { env } from '../../../env'; +import advancedSettings$ from '@Context/AdvancedSettings'; + +/** + * React hook to get PublisherProperties combining default options and options set in UserContext + * @returns {PublisherProperties | null} publisher properties object + */ + +const usePublisherOptions = ({ + isAudioEnabled, + isVideoEnabled, +}: { + isAudioEnabled: boolean; + isVideoEnabled: boolean; +}): PublisherProperties => { + const { user } = useUserContext(); + const enableDtx = advancedSettings$.use.select((state) => state.enableDtx); + const frameRate = advancedSettings$.use.select((state) => state.frameRate); + const codecMode = advancedSettings$.use.select((state) => state.codecMode); + const codecPriority = advancedSettings$.use.select((state) => state.codecPriority); + const publisherAudioFallbackEnabled = advancedSettings$.use.select( + (state) => state.publisherAudioFallbackEnabled + ); + const subscriberAudioFallbackEnabled = advancedSettings$.use.select( + (state) => state.subscriberAudioFallbackEnabled + ); + + // Extract individual properties to avoid object reference changes + const { name, noiseSuppression, backgroundFilter, publishAudio, publishVideo, publishCaptions } = + user.defaultSettings; + + const videoSource = useDeviceId('videoinput'); + const audioSource = useDeviceId('audioinput'); + + const getOptions = useStableCallback(() => { + const initials = getInitials(name); + + const audioFilter: AudioFilter | undefined = + noiseSuppression && hasMediaProcessorSupport('both') + ? { type: 'advancedNoiseSuppression' } + : undefined; + + const videoFilter: VideoFilter | undefined = + backgroundFilter && hasMediaProcessorSupport('both') ? backgroundFilter : undefined; + + const options = { + audioFallback: { + publisher: publisherAudioFallbackEnabled, + subscriber: subscriberAudioFallbackEnabled, + }, + audioFilter, + audioSource, + enableDtx, + initials, + insertDefaultUI: false, + name, + publishAudio: env.ALLOW_AUDIO_ON_JOIN && publishAudio && isAudioEnabled, + publishCaptions, + publishVideo: env.ALLOW_VIDEO_ON_JOIN && publishVideo && isVideoEnabled, + frameRate, + preferredVideoCodecs: (codecMode === 'automatic' + ? 'automatic' + : codecPriority) as PublisherProperties['preferredVideoCodecs'], + resolution: env.PUBLISHER_MAX_RESOLUTION, + videoFilter, + videoSource, + }; + + return options; + }); + + return useMemo( + () => getOptions(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + getOptions, + audioSource, + backgroundFilter, + enableDtx, + name, + noiseSuppression, + publishAudio, + publishCaptions, + publishVideo, + videoSource, + isAudioEnabled, + isVideoEnabled, + frameRate, + codecMode, + codecPriority, + publisherAudioFallbackEnabled, + subscriberAudioFallbackEnabled, + ] + ); +}; + +export default usePublisherOptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/index.tsx new file mode 100644 index 00000000..135c413a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/index.tsx @@ -0,0 +1,3 @@ +import usePublisherQuality from './usePublisherQuality'; + +export default usePublisherQuality; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.spec.tsx new file mode 100644 index 00000000..d333f164 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.spec.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Publisher } from '@vonage/client-sdk-video'; +import EventEmitter from 'events'; +import useUserContext from '@hooks/useUserContext'; +import type { UserContextType } from '../../user'; +import usePublisherQuality from './usePublisherQuality'; + +vi.mock('@hooks/useUserContext.tsx'); + +const mockUserContext: UserContextType = { + user: { + defaultSettings: { + publishAudio: false, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, + }, + issues: { + reconnections: 0, + audioFallbacks: 0, + }, + }, + setUser: vi.fn(), +}; + +describe('usePublisherQuality', () => { + beforeEach(() => { + vi.mocked(useUserContext).mockImplementation(() => mockUserContext); + }); + + it('should set quality to good on videoEnabled event', async () => { + const mockPublisher = new EventEmitter(); + const { result } = renderHook(() => usePublisherQuality(mockPublisher as unknown as Publisher)); + void act(() => mockPublisher.emit('videoEnabled')); + await waitFor(() => expect(result.current).toBe('good')); + }); + + it('should set quality to good on videoDisableWarningLifted event', async () => { + const mockPublisher = new EventEmitter(); + const { result } = renderHook(() => usePublisherQuality(mockPublisher as unknown as Publisher)); + void act(() => mockPublisher.emit('videoDisableWarningLifted')); + await waitFor(() => expect(result.current).toBe('good')); + }); + + it('should set quality to good on videoDisabled event', async () => { + const mockPublisher = new EventEmitter(); + const { result } = renderHook(() => usePublisherQuality(mockPublisher as unknown as Publisher)); + void act(() => mockPublisher.emit('videoDisabled')); + await waitFor(() => expect(result.current).toBe('bad')); + expect(useUserContext().user.issues.audioFallbacks).toBe(1); + }); + + it('should set quality to good on videoDisableWarning event', async () => { + const mockPublisher = new EventEmitter(); + const { result } = renderHook(() => usePublisherQuality(mockPublisher as unknown as Publisher)); + void act(() => mockPublisher.emit('videoDisableWarning')); + await waitFor(() => expect(result.current).toBe('poor')); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx new file mode 100644 index 00000000..ad69cd8d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/PublisherProvider/usePublisherQuality/usePublisherQuality.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Publisher } from '@vonage/client-sdk-video'; +import useUserContext from '../../../hooks/useUserContext'; + +export type NetworkQuality = 'good' | 'poor' | 'bad'; + +/** + * React hook to return react state string of current publisher network quality. + * This hook adds the necessary event listeners to publisher to assign state to one of 3 values: + * 'good' | 'poor' | 'bad'. + * Network quality state will be updated when events are triggered. + * @param {(Publisher | null)} publisher - The publisher to monitor + * @returns {NetworkQuality} network quality + */ +const usePublisherQuality = (publisher: Publisher | null): NetworkQuality => { + const { user } = useUserContext(); + const [quality, setQuality] = useState('good'); + + const handleVideoDisabled = useCallback(() => { + setQuality('bad'); + + // eslint-disable-next-line react-hooks/immutability + user.issues.audioFallbacks += 1; + }, [user]); + + const handleVideoEnabled = useCallback(() => { + setQuality('good'); + }, []); + + const handleVideoWarning = useCallback(() => { + setQuality('poor'); + }, []); + + const handleVideoWarningLifted = useCallback(() => { + setQuality('good'); + }, []); + + useEffect(() => { + if (publisher) { + publisher.on('videoDisabled', handleVideoDisabled); + publisher.on('videoEnabled', handleVideoEnabled); + publisher.on('videoDisableWarning', handleVideoWarning); + publisher.on('videoDisableWarningLifted', handleVideoWarningLifted); + } + }, [ + handleVideoDisabled, + handleVideoEnabled, + handleVideoWarning, + handleVideoWarningLifted, + publisher, + ]); + + return quality; +}; + +export default usePublisherQuality; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/RoomProvider.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/RoomProvider.tsx new file mode 100644 index 00000000..4f47d4bf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/RoomProvider.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; +import { BackgroundPublisherProvider } from './BackgroundPublisherProvider'; + +/** + * Old RoomContext renamed to RoomProvider + * Wrapper for all of the contexts used by the waiting room and the meeting room. + * @returns {ReactElement} The context. + */ +const RoomProvider: React.FC = ({ children }) => ( + {children} +); + +export default RoomProvider; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.spec.tsx new file mode 100644 index 00000000..39a82c65 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.spec.tsx @@ -0,0 +1,516 @@ +import { ReactElement, useEffect } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, render as renderBase, waitFor } from '@testing-library/react'; +import EventEmitter from 'events'; +import { Publisher, Stream } from '@vonage/client-sdk-video'; +import useSessionContext from '@hooks/useSessionContext'; +import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker'; +import VonageVideoClient from '@utils/VonageVideoClient'; +import { StreamPropertyChangedEvent, SubscriberWrapper } from '@app-types/session'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import type { VideoClient } from '@core/services'; + +vi.mock('@utils/ActiveSpeakerTracker'); +vi.mock('@utils/VonageVideoClient'); + +// Override the constants for max pinning test +vi.mock('@utils/constants', () => ({ + MAX_PIN_COUNT_MOBILE: 1, + MAX_PIN_COUNT_DESKTOP: 1, +})); + +vi.mock('@api/fetchCredentials'); + +const mockJoinSessionMutate = vi.fn(); +const mockVideoClient = { + joinSession: (...args: unknown[]) => mockJoinSessionMutate(...args) as unknown, +} as unknown as VideoClient; + +// A valid fake JWT containing sessionId: '1_MX4xMjM0NTY3OH4-VGh1IEZlYiAyNyAwODozMjozNCBQU1QgMjAyMH4wLjI0NDYxMjE' +const validSessionKey = + 'eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOiIxX01YNHhNak0wTlRZM09INC1WR2gxSUVabFlpQXlOeUF3T0Rvek1qb3pOQ0JRVTFRZ01qQXlNSDR3TGpJME5EWXhNakUiLCJyb29tTmFtZSI6IlRlc3RDb21wb25lbnRSb29tIn0.fakesig'; + +describe('SessionProvider', () => { + let activeSpeakerTracker: ActiveSpeakerTracker; + let vonageVideoClient: VonageVideoClient; + + const TestComponent = () => { + const { + activeSpeakerId, + publish, + unpublish, + joinRoom, + disconnect, + subscriberWrappers, + connected, + reconnecting, + archiveId, + forceMute, + pinSubscriber, + isMaxPinned, + lastStreamUpdate, + } = useSessionContext(); + + useEffect(() => { + if (joinRoom) { + void joinRoom({ sessionKey: validSessionKey }); + } + }, [joinRoom]); + + return ( +
        + + + + + + {activeSpeakerId} + + {subscriberWrappers.map((subscriberWrapper) => ( +
        {subscriberWrapper.id}
        + ))} +
        + {String(connected)} + {String(reconnecting)} + {String(archiveId)} + {String(isMaxPinned)} + + {lastStreamUpdate ? JSON.stringify(lastStreamUpdate) : 'No updates'} + +
        + ); + }; + + beforeEach(() => { + activeSpeakerTracker = Object.assign(new EventEmitter(), { + onSubscriberDestroyed: vi.fn(), + onSubscriberAudioLevelUpdated: vi.fn(), + }) as unknown as ActiveSpeakerTracker; + + vonageVideoClient = Object.assign(new EventEmitter(), { + unpublish: vi.fn(), + publish: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockReturnValue(Promise.resolve()), + disconnect: vi.fn(), + forceMuteStream: vi.fn(), + hasStream: vi.fn().mockReturnValue(true), + resubscribeToStreamId: vi.fn().mockResolvedValue(undefined), + emitSubscriberDestroyedOnce: vi.fn(), + }) as unknown as VonageVideoClient; + + const mockedActiveSpeakerTracker = vi.mocked(ActiveSpeakerTracker); + + mockedActiveSpeakerTracker.mockImplementation(() => { + return activeSpeakerTracker; + }); + + const mockedVonageVideoClient = vi.mocked(VonageVideoClient); + + mockedVonageVideoClient.mockImplementation(() => { + return vonageVideoClient; + }); + + mockJoinSessionMutate.mockResolvedValue({ + token: 'token', + sessionId: 'sessionId', + sessionKey: validSessionKey, + }); + }); + + async function renderAndWaitForConnection() { + const result = render(); + await waitFor(() => expect(result.getByTestId('connected')).toHaveTextContent('true')); + return result; + } + + it('should update activeSpeaker state when activeSpeakerTracker emits event', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + void act(() => + activeSpeakerTracker.emit('activeSpeakerChanged', { + previousActiveSpeaker: { subscriberId: undefined, movingAvg: 0 }, + newActiveSpeaker: { subscriberId: 'sub1', movingAvg: 0.3 }, + }) + ); + await waitFor(() => expect(getByTestId('activeSpeaker')).toHaveTextContent('sub1')); + void act(() => + activeSpeakerTracker.emit('activeSpeakerChanged', { + previousActiveSpeaker: { subscriberId: 'sub1', movingAvg: 0 }, + newActiveSpeaker: { subscriberId: 'sub2', movingAvg: 0.4 }, + }) + ); + await waitFor(() => expect(getByTestId('activeSpeaker')).toHaveTextContent('sub2')); + }); + + describe('publish', () => { + it('should call publish on VonageVideoClient when connected', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('publish').click(); + }); + + await waitFor(() => { + expect(vonageVideoClient.publish).toHaveBeenCalledTimes(1); + }); + }); + + it('should not call publish on VonageVideoClient if not connected', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('disconnect').click(); + }); + + await waitFor(() => expect(getByTestId('connected')).toHaveTextContent('false')); + + act(() => { + getByTestId('publish').click(); + }); + + expect(vonageVideoClient.publish).toHaveBeenCalledTimes(0); + }); + }); + + describe('unpublish', () => { + it('should call unpublish on VonageVideoClient', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('unpublish').click(); + }); + + await waitFor(() => { + expect(vonageVideoClient.unpublish).toHaveBeenCalledTimes(1); + }); + }); + + it('should not call unpublish on VonageVideoClient if not connected', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('disconnect').click(); + }); + + await waitFor(() => expect(getByTestId('connected')).toHaveTextContent('false')); + + act(() => { + getByTestId('unpublish').click(); + }); + + await waitFor(() => { + expect(vonageVideoClient.unpublish).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('subscriberWrappers', () => { + it('adding a new subscriber should add it to the subscriberWrappers', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub1', + } as unknown as SubscriberWrapper); + }); + + await waitFor(() => { + expect(getByTestId('subscriberWrappers')).toHaveTextContent('sub1'); + }); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub2', + } as unknown as SubscriberWrapper); + }); + + await waitFor(() => { + expect(getByTestId('subscriberWrappers')).toHaveTextContent('sub2'); + expect(getByTestId('subscriberWrappers').children.length).toBe(2); + }); + }); + + it('removing a subscriber should remove it from the subscriberWrappers', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub1', + } as unknown as SubscriberWrapper); + }); + + await waitFor(() => { + expect(getByTestId('subscriberWrappers')).toHaveTextContent('sub1'); + }); + + act(() => { + vonageVideoClient.hasStream = vi.fn().mockReturnValue(false); + vonageVideoClient.emit('subscriberDestroyed', 'sub1'); + }); + + await waitFor(() => { + expect(getByTestId('subscriberWrappers')).not.toHaveTextContent('sub1'); + }); + }); + }); + + describe('session', () => { + it('connect should call connect on VonageVideoClient and set connected to true', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + await waitFor(() => expect(getByTestId('connected')).toHaveTextContent('true')); + expect(vonageVideoClient.connect).toHaveBeenCalledTimes(1); + }); + + it('disconnect should call disconnect on VonageVideoClient and set connected to false', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('disconnect').click(); + }); + + await waitFor(() => expect(getByTestId('connected')).toHaveTextContent('false')); + expect(vonageVideoClient.disconnect).toHaveBeenCalledTimes(1); + }); + + it('when reconnecting session, sets reconnecting to true', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('sessionReconnecting'); + }); + + await waitFor(() => expect(getByTestId('reconnecting')).toHaveTextContent('true')); + }); + + it('when reconnected, sets reconnecting to false', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('sessionReconnected'); + }); + + await waitFor(() => expect(getByTestId('reconnecting')).toHaveTextContent('false')); + }); + + it('when re-connected, sets reconnecting to false', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('sessionReconnected'); + }); + + await waitFor(() => expect(getByTestId('reconnecting')).toHaveTextContent('false')); + }); + + it('when disconnected, sets connected to false', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('sessionDisconnected', { reason: 'test reason' }); + }); + + await waitFor(() => expect(getByTestId('connected')).toHaveTextContent('false')); + }); + + it('when a stream property changes, it should update the state', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + const streamPropertyChangedEvent = { + stream: { + id: 'stream1', + } as unknown as Stream, + changedProperty: 'hasVideo', + newValue: false, + oldValue: true, + } as unknown as StreamPropertyChangedEvent; + + await waitFor(() => { + expect(getByTestId('streamPropertyChanged')).toHaveTextContent('No updates'); + }); + + act(() => { + vonageVideoClient.emit('streamPropertyChanged', streamPropertyChangedEvent); + }); + + await waitFor(() => { + expect(getByTestId('streamPropertyChanged')).toHaveTextContent( + JSON.stringify(streamPropertyChangedEvent) + ); + }); + }); + }); + + describe('archiving', () => { + it('should set archiveId when archiving starts', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('archiveStarted', 'abc123'); + }); + + await waitFor(() => expect(getByTestId('archiveId')).toHaveTextContent('abc123')); + }); + + it('should set archiveId to null when archiving stops', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('archiveStopped'); + }); + + await waitFor(() => expect(getByTestId('archiveId')).toHaveTextContent('null')); + }); + }); + + describe('pinning', () => { + it('should move a pinned subscriber to the top of the list', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub1', + } as unknown as SubscriberWrapper); + }); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub2', + } as unknown as SubscriberWrapper); + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub3', + } as unknown as SubscriberWrapper); + }); + + await waitFor(() => expect(getByTestId('subscriberWrappers').children.length).toBe(3)); + expect(getByTestId('subscriberWrappers').children[0]).not.toHaveTextContent('sub1'); + + act(() => { + getByTestId('pinSubscriber').click(); + }); + + expect(getByTestId('subscriberWrappers').children[0]).toHaveTextContent('sub1'); + }); + + it('pinning the maximum number of subscribers should set isMaxPinned to true', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub1', + } as unknown as SubscriberWrapper); + + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub2', + } as unknown as SubscriberWrapper); + + vonageVideoClient.emit('subscriberVideoElementCreated', { + id: 'sub3', + } as unknown as SubscriberWrapper); + + getByTestId('pinSubscriber').click(); + }); + + await waitFor(() => expect(getByTestId('isMaxPinned')).toHaveTextContent('true')); + }); + }); + + it('forceMute should call forceMute on VonageVideoClient', async () => { + const { getByTestId } = await renderAndWaitForConnection(); + + act(() => { + getByTestId('forceMute').click(); + }); + + await waitFor(() => expect(vonageVideoClient.forceMuteStream).toHaveBeenCalledTimes(1)); + }); + + it('joinRoom should call joinSession and connect', async () => { + await renderAndWaitForConnection(); + + await waitFor(() => { + expect(mockJoinSessionMutate).toHaveBeenCalledTimes(1); + expect(vonageVideoClient.connect).toHaveBeenCalledTimes(1); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render(ui: ReactElement, { userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + userContext: { + ...userContext, + value: { + defaultSettings: { + publishAudio: false, + publishVideo: false, + name: '', + noiseSuppression: true, + publishCaptions: false, + ...userContext?.value?.defaultSettings, + }, + }, + }, + sessionContext, + runtimeContext: { videoClient: mockVideoClient }, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.tsx new file mode 100644 index 00000000..c0f1de64 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/SessionProvider/session.tsx @@ -0,0 +1,639 @@ +import { + useState, + createContext, + useRef, + ReactNode, + useMemo, + useCallback, + Dispatch, + SetStateAction, + useEffect, + ReactElement, +} from 'react'; +import { Connection, Publisher, Stream } from '@vonage/client-sdk-video'; +import useRightPanel, { RightPanelActiveTab } from '@hooks/useRightPanel'; +import useUserContext from '@hooks/useUserContext'; +import useChat from '@hooks/useChat'; +import useEmoji, { EmojiWrapper } from '@hooks/useEmoji'; +import ActiveSpeakerTracker from '@utils/ActiveSpeakerTracker'; +import { + Credential, + LocalCaptionReceived, + SignalEvent, + StreamPropertyChangedEvent, + SubscriberAudioLevelUpdatedEvent, + SubscriberWrapper, + LayoutMode, + LAYOUT_MODES, +} from '@app-types/session'; +import { ChatMessageType } from '@app-types/chat'; +import { isMobile } from '@web/platform'; +import { + sortByDisplayPriority, + togglePinAndSortByDisplayOrder, +} from '@utils/sessionStateOperations'; +import { MAX_PIN_COUNT_DESKTOP, MAX_PIN_COUNT_MOBILE } from '@utils/constants'; +import VonageVideoClient from '@utils/VonageVideoClient'; +import wait from '@common/execution/wait'; +import { env } from '../../env'; +import frontendLogger from '../../logger'; +import { runtime$ } from '@core/stores'; +import { decodeSessionKey } from '@common/helpers'; +import type { VideoSessionDetails } from '@common/types'; + +export type { ChatMessageType } from '@app-types/chat'; + +export type SessionContextType = { + vonageVideoClient: null | VonageVideoClient; + disconnect: null | (() => void); + joinRoom: null | ((params: { sessionKey: string }) => Promise); + forceMute: null | ((stream: Stream) => Promise); + connected: null | boolean; + sessionKey: string | null; + sessionDetails: VideoSessionDetails | null; + unreadCount: number; + messages: ChatMessageType[]; + sendChatMessage: (text: string) => void; + reconnecting: null | boolean; + subscriberWrappers: SubscriberWrapper[]; + activeSpeakerId: string | undefined; + layoutMode: LayoutMode; + setLayoutMode: Dispatch>; + archiveId: string | null; + archiveIdStartedBySelf: string | null; + recordingAlreadyNotified: boolean; + setRecordingAlreadyNotified: Dispatch>; + markArchiveStartRequestedBySelf: () => void; + resetArchiveStartRequestedBySelf: () => void; + rightPanelActiveTab: RightPanelActiveTab; + toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; + toggleChat: () => void; + closeRightPanel: () => void; + toggleReportIssue: () => void; + pinSubscriber: (subscriberId: string) => void; + isMaxPinned: boolean; + ownCaptions: string | null; + sendEmoji: (emoji: string) => void; + emojiQueue: EmojiWrapper[]; + publish: (publisher: Publisher) => Promise; + unpublish: (publisher: Publisher) => void; + lastStreamUpdate: StreamPropertyChangedEvent | null; + subscriptionError: Error | null; +}; + +/** + * Context to provide session-related data and actions. + */ +export const SessionContext = createContext({ + vonageVideoClient: null, + disconnect: null, + joinRoom: null, + forceMute: null, + connected: null, + sessionKey: null, + sessionDetails: null, + unreadCount: 0, + messages: [], + sendChatMessage: () => {}, + reconnecting: null, + subscriberWrappers: [], + activeSpeakerId: undefined, + layoutMode: 'grid', + setLayoutMode: () => {}, + archiveId: null, + archiveIdStartedBySelf: null, + recordingAlreadyNotified: false, + setRecordingAlreadyNotified: () => {}, + markArchiveStartRequestedBySelf: () => {}, + resetArchiveStartRequestedBySelf: () => {}, + rightPanelActiveTab: 'closed', + toggleParticipantList: () => {}, + toggleBackgroundEffects: () => {}, + toggleChat: () => {}, + closeRightPanel: () => {}, + toggleReportIssue: () => {}, + pinSubscriber: () => {}, + isMaxPinned: false, + ownCaptions: null, + sendEmoji: () => {}, + emojiQueue: [], + publish: async () => Promise.resolve(), + unpublish: () => {}, + lastStreamUpdate: null, + subscriptionError: null, +}); + +export type ConnectionEventType = { connection: Connection; reason?: string; id?: string }; + +export type SessionContextInitialValue = Partial< + Pick< + SessionContextType, + | 'connected' + | 'reconnecting' + | 'layoutMode' + | 'subscriberWrappers' + | 'lastStreamUpdate' + | 'subscriptionError' + | 'ownCaptions' + | 'archiveId' + | 'activeSpeakerId' + | 'recordingAlreadyNotified' + | 'archiveIdStartedBySelf' + | 'sessionKey' + > +>; + +/** + * @typedef {object} SessionProviderProps + * @property {ReactNode} children - The content to be rendered as children. + * @property {SessionContextInitialValue} initialValue - Optional initial values for session context state. + * @property {boolean} skipBrowserUrlUpdate - When true, skips updating the browser URL on join (for MemoryRouter / embedded contexts). + */ +export type SessionProviderProps = { + children: ReactNode; + initialValue?: SessionContextInitialValue; + /** Set to true when running inside a MemoryRouter to avoid overwriting the host page URL. */ + skipBrowserUrlUpdate?: boolean; +}; + +const MAX_PIN_COUNT = isMobile() ? MAX_PIN_COUNT_MOBILE : MAX_PIN_COUNT_DESKTOP; + +/** + * SessionProvider - React Context Provider for SessionProvider + * Provides session context to the component tree, including managing subscribers, connections, and layout mode. + * We use Context to make the Session object available in many components across the app without + * prop drilling: https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context + * @param {SessionProviderProps} props - The provider properties + * @returns {SessionContextType} a context provider for a publisher preview + */ +const SessionProvider = ({ + children, + initialValue = {}, + skipBrowserUrlUpdate = false, +}: SessionProviderProps): ReactElement => { + const videoClient = runtime$.useVideoClient(); + const [lastStreamUpdate, setLastStreamUpdate] = useState( + initialValue?.lastStreamUpdate ?? null + ); + const vonageVideoClient = useRef(null); + const [reconnecting, setReconnecting] = useState(initialValue?.reconnecting ?? false); + const [subscriberWrappers, setSubscriberWrappers] = useState( + initialValue?.subscriberWrappers ?? [] + ); + const [subscriptionError, setSubscriptionError] = useState( + initialValue?.subscriptionError ?? null + ); + const [ownCaptions, setOwnCaptions] = useState(initialValue?.ownCaptions ?? null); + + const [layoutMode, setLayoutMode] = useState( + initialValue?.layoutMode ?? + (() => { + const isValidLayoutMode = (LAYOUT_MODES as readonly string[]).includes( + env.DEFAULT_LAYOUT_MODE + ); + return isValidLayoutMode ? env.DEFAULT_LAYOUT_MODE : 'grid'; + })() + ); + + const [archiveId, setArchiveId] = useState(initialValue?.archiveId ?? null); + const [archiveIdStartedBySelf, setArchiveIdStartedBySelf] = useState( + initialValue?.archiveIdStartedBySelf ?? null + ); + const [recordingAlreadyNotified, setRecordingAlreadyNotified] = useState( + initialValue?.recordingAlreadyNotified ?? false + ); + const archiveStartRequestedBySelfRef = useRef(false); + + const markArchiveStartRequestedBySelf = useCallback(() => { + archiveStartRequestedBySelfRef.current = true; + }, []); + + const resetArchiveStartRequestedBySelf = useCallback(() => { + archiveStartRequestedBySelfRef.current = false; + }, []); + const activeSpeakerTracker = useRef(new ActiveSpeakerTracker()); + const [activeSpeakerId, setActiveSpeakerId] = useState( + initialValue?.activeSpeakerId ?? undefined + ); + const activeSpeakerIdRef = useRef(undefined); + const { messages, onChatMessage, sendChatMessage } = useChat({ + signal: vonageVideoClient.current?.signal, + }); + const getConnectionId = useCallback((): string | undefined => { + return vonageVideoClient.current?.connectionId; + }, []); + const { sendEmoji, onEmoji, emojiQueue } = useEmoji({ + signal: vonageVideoClient.current?.signal, + getConnectionId, + }); + const { + closeRightPanel, + toggleParticipantList, + toggleBackgroundEffects, + toggleChat, + rightPanelState: { unreadCount, activeTab: rightPanelActiveTab }, + incrementUnreadCount, + toggleReportIssue, + } = useRightPanel(); + + const handleChatSignal = ({ data }: SignalEvent) => { + if (data) { + onChatMessage(data); + incrementUnreadCount(); + } + }; + + const handleEmoji = useCallback( + (emojiEvent: SignalEvent) => { + setSubscriberWrappers((currentSubscriberWrappers) => { + onEmoji(emojiEvent, currentSubscriberWrappers); + return [...currentSubscriberWrappers]; // Return unchanged state + }); + }, + [onEmoji] + ); + + const setActiveSpeakerIdAndRef = useCallback((id: string | undefined) => { + // We store the current active speaker id in a ref so it can be accessed later when sorting the subscriberWrappers for display. + activeSpeakerIdRef.current = id; + setActiveSpeakerId(id); + }, []); + + /** + * Moves the subscriber with the specified ID to the top of the display order. + * @param {string} id - The ID of the subscriber to move. + */ + const moveSubscriberToTopOfDisplayOrder = useCallback((id: string) => { + setSubscriberWrappers((prevSubscriberWrappers) => { + const activeSpeakerWrapper = prevSubscriberWrappers.find( + ({ id: streamId }) => streamId === id + ); + if (activeSpeakerWrapper) { + // We use a ref to access the value for activeSpeakerId, because it is not updated in the context of this state and shows up as its initial state, undefined. + // When passing the sort function callback, the initial value of activeSpeakerId is captured when the listener is added. Updates to the + // activeSpeakerId are not reflected when it is accessed. A workaround is to use useRef to store state. + // See: https://stackoverflow.com/questions/53845595/wrong-react-hooks-behaviour-with-event-listener + return prevSubscriberWrappers.sort(sortByDisplayPriority(id)); + } + return prevSubscriberWrappers; + }); + }, []); + + /** + * isMaxPinned {boolean} - whether the maximum number of allowed pinned participants has been reached. + * This is used to disable the pin buttons so no more participants can be pinned. + */ + const isMaxPinned = useMemo(() => { + const pinnedCount = subscriberWrappers.filter((sub) => sub.isPinned).length; + return pinnedCount >= MAX_PIN_COUNT; + }, [subscriberWrappers]); + + /** + * Toggles a subscriber's isPinned field, and sorts subscribers by display priority. + * @param {string} id - The ID of the subscriber to pin. + */ + const pinSubscriber = useCallback( + (id: string) => { + setSubscriberWrappers((previousSubscriberWrappers) => { + return togglePinAndSortByDisplayOrder(id, previousSubscriberWrappers, activeSpeakerId); + }); + }, + [activeSpeakerId] + ); + + // hook to keep track of the active speaker during the call and move it to the top of the display order + useEffect(() => { + activeSpeakerTracker.current.on('activeSpeakerChanged', ({ newActiveSpeaker }) => { + const { subscriberId } = newActiveSpeaker; + setActiveSpeakerIdAndRef(subscriberId); + if (subscriberId) { + moveSubscriberToTopOfDisplayOrder(subscriberId); + } + }); + }, [moveSubscriberToTopOfDisplayOrder, setActiveSpeakerIdAndRef]); + + const { user } = useUserContext(); + const [connected, setConnected] = useState(initialValue?.connected ?? false); + const [sessionKey, setSessionKey] = useState(initialValue?.sessionKey ?? null); + + const sessionDetails = useMemo(() => { + if (!sessionKey) return null; + + return decodeSessionKey({ sessionKey }); + }, [sessionKey]); + + /** + * Handles changes to stream properties. This triggers a re-render when a stream property changes + * @param {StreamPropertyChangedEvent} event - The event containing the stream, changed property, new value, and old value. + */ + const handleStreamPropertyChanged = (event: StreamPropertyChangedEvent) => { + const { stream, changedProperty, newValue, oldValue } = event; + // Without a re-render during a stream change, we don't get visual indicators for a subscriber + // muting themselves or the initials being displayed. + setLastStreamUpdate({ stream, changedProperty, newValue, oldValue }); + }; + + // handle the disconnect from session and clean up of the session object + const handleSessionDisconnected = () => { + vonageVideoClient.current = null; + setConnected(false); + }; + + // function to set reconnecting status and to increase the number of reconnections the user has had + // this reconnection count can be then used in the UI to provide user feedback or for post-call analytics + const handleReconnecting = () => { + if (user) { + user.issues.reconnections += 1; + } + + setReconnecting(true); + }; + const handleReconnected = () => { + setReconnecting(false); + setSubscriptionError(null); + }; + + const handleArchiveStarted = (id: string) => { + setArchiveId(id); + + if (!archiveStartRequestedBySelfRef.current) { + return; + } + + setArchiveIdStartedBySelf(id); + archiveStartRequestedBySelfRef.current = false; + }; + + const handleArchiveStopped = () => { + setArchiveId(null); + setArchiveIdStartedBySelf(null); + archiveStartRequestedBySelfRef.current = false; + }; + + const handleSubscriberVideoElementCreated = (subscriberWrapper: SubscriberWrapper) => { + setSubscriberWrappers((previousSubscriberWrappers) => + [ + subscriberWrapper, + ...previousSubscriberWrappers.filter(({ id }) => id !== subscriberWrapper.id), + ].toSorted(sortByDisplayPriority(activeSpeakerIdRef.current)) + ); + }; + + const handleLocalCaptionReceived = (event: LocalCaptionReceived) => { + setOwnCaptions(event.caption); + }; + + const handleSubscriberDestroyed = async (streamId: string) => { + await wait(500); + + const doesStreamStillExistInSession = (() => { + if (!vonageVideoClient.current) { + return false; + } + + return vonageVideoClient.current.hasStream(streamId); + })(); + + console.warn(`Subscriber with stream ID ${streamId} destroyed`, { + doesStreamStillExistInSession, + }); + + if (doesStreamStillExistInSession) { + void vonageVideoClient.current!.resubscribeToStreamId(streamId); + console.warn(`Subscriber with stream ID ${streamId} resubscribing`); + return; + } + destroySubscriber(streamId); + }; + + function destroySubscriber(streamId: string) { + activeSpeakerTracker.current.onSubscriberDestroyed(streamId); + const isNotDestroyedStreamId = ({ id }: { id: string }) => streamId !== id; + setSubscriberWrappers((prevSubscriberWrappers) => + prevSubscriberWrappers.filter(isNotDestroyedStreamId) + ); + console.warn(`Subscriber stream ID ${streamId} destroyed and cleaned up`); + } + + const handleSubscriberAudioLevelUpdated = ({ + movingAvg, + subscriberId, + }: SubscriberAudioLevelUpdatedEvent) => { + activeSpeakerTracker.current.onSubscriberAudioLevelUpdated({ subscriberId, movingAvg }); + }; + + const handleSubscriptionError = useCallback( + (error: unknown) => { + const isBrowserOnline = (() => { + if (typeof navigator === 'undefined') return true; + return navigator.onLine; + })(); + + if (reconnecting || isBrowserOnline === false) { + frontendLogger.log('Session: ignoring subscription error during reconnection/offline', { + reconnecting, + isBrowserOnline, + }); + return; + } + + setSubscriptionError( + error instanceof Error ? error : new Error('Unknown subscription error') + ); + }, + [reconnecting] + ); + + /** + * Connects to the session using the provided credentials. + * @param {Credential} credential - The sessionKey and token for the session. + * @returns {Promise} A promise that resolves when the session is connected. + */ + const connect = useCallback(async (credential: Credential) => { + if (vonageVideoClient.current) { + return; + } + try { + vonageVideoClient.current = new VonageVideoClient(credential); + vonageVideoClient.current.on('streamPropertyChanged', handleStreamPropertyChanged); + vonageVideoClient.current.on('sessionReconnecting', handleReconnecting); + vonageVideoClient.current.on('sessionReconnected', handleReconnected); + vonageVideoClient.current.on('sessionDisconnected', handleSessionDisconnected); + vonageVideoClient.current.on('archiveStarted', handleArchiveStarted); + vonageVideoClient.current.on('archiveStopped', handleArchiveStopped); + vonageVideoClient.current.on('signal:chat', handleChatSignal); + vonageVideoClient.current.on('signal:emoji', handleEmoji); + vonageVideoClient.current.on( + 'subscriberAudioLevelUpdated', + handleSubscriberAudioLevelUpdated + ); + vonageVideoClient.current.on( + 'subscriberVideoElementCreated', + handleSubscriberVideoElementCreated + ); + vonageVideoClient.current.on('subscriberDestroyed', handleSubscriberDestroyed); + vonageVideoClient.current.on('localCaptionReceived', handleLocalCaptionReceived); + vonageVideoClient.current.on('subscriptionError', handleSubscriptionError); + + await vonageVideoClient.current.connect(); + setConnected(true); + } catch (err: unknown) { + console.error(err); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Joins a room by fetching an ephemeral token for the given sessionKey and connecting. + * @param {object} params - The join parameters. + * @param {string} params.sessionKey - The session key to join. + */ + const joinRoom = useCallback( + async (args: { sessionKey: string }) => { + const session = await videoClient.joinSession({ + sessionKey: args.sessionKey, + }); + + setSessionKey(args.sessionKey); + if (!skipBrowserUrlUpdate) { + window.history.replaceState(null, '', `/room/${args.sessionKey}`); + } + + return connect({ + sessionKey: args.sessionKey, + token: session.token, + }); + }, + [connect, videoClient, skipBrowserUrlUpdate] + ); + + /** + * Disconnects from the current session and cleans up session-related resources. + */ + const disconnect = useCallback(() => { + if (vonageVideoClient.current) { + vonageVideoClient.current.disconnect(); + vonageVideoClient.current = null; + + setConnected(false); + } + }, []); + + /** + * Force mutes a participant. + * @param {Stream} stream - The stream that is going to be muted. + */ + const forceMute = useCallback(async (stream: Stream) => { + if (vonageVideoClient.current) { + await vonageVideoClient.current.forceMuteStream(stream); + } + }, []); + + /** + * Publishes a stream to the session. + * @param {Publisher} publisher - The publisher object representing the stream to be published. + * @returns {Promise} A promise that resolves when the stream is successfully published. + */ + const publish = useCallback(async (publisher: Publisher): Promise => { + if (!vonageVideoClient.current) { + return; + } + await vonageVideoClient.current.publish(publisher); + }, []); + + /** + * Unpublishes a stream from the session. + * @param {Publisher} publisher - The publisher object representing the stream to be unpublished. + */ + const unpublish = useCallback( + (publisher: Publisher) => { + if (vonageVideoClient.current) { + vonageVideoClient.current.unpublish(publisher); + } + }, + [vonageVideoClient] + ); + + const value = useMemo( + () => ({ + activeSpeakerId, + archiveId, + archiveIdStartedBySelf, + markArchiveStartRequestedBySelf, + resetArchiveStartRequestedBySelf, + vonageVideoClient: vonageVideoClient.current, + disconnect, + joinRoom, + forceMute, + connected, + sessionKey, + sessionDetails, + unreadCount, + messages, + sendChatMessage, + reconnecting, + subscriberWrappers, + layoutMode, + recordingAlreadyNotified, + setRecordingAlreadyNotified, + setLayoutMode, + rightPanelActiveTab, + toggleParticipantList, + toggleBackgroundEffects, + toggleChat, + closeRightPanel, + toggleReportIssue, + pinSubscriber, + isMaxPinned, + ownCaptions, + sendEmoji, + emojiQueue, + publish, + unpublish, + lastStreamUpdate, + subscriptionError, + }), + [ + activeSpeakerId, + archiveId, + archiveIdStartedBySelf, + markArchiveStartRequestedBySelf, + resetArchiveStartRequestedBySelf, + setRecordingAlreadyNotified, + recordingAlreadyNotified, + vonageVideoClient, + disconnect, + unreadCount, + messages, + sendChatMessage, + joinRoom, + forceMute, + connected, + sessionKey, + sessionDetails, + reconnecting, + subscriberWrappers, + layoutMode, + setLayoutMode, + rightPanelActiveTab, + toggleParticipantList, + toggleBackgroundEffects, + toggleChat, + closeRightPanel, + toggleReportIssue, + pinSubscriber, + isMaxPinned, + ownCaptions, + sendEmoji, + emojiQueue, + publish, + unpublish, + lastStreamUpdate, + subscriptionError, + ] + ); + + return {children}; +}; +export default SessionProvider; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/RoomContext.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/RoomContext.spec.tsx new file mode 100644 index 00000000..fcd98721 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/RoomContext.spec.tsx @@ -0,0 +1,117 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import type { MediaDeviceInfoJSON } from '@web/types'; +import { Route, Routes } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, ReactElement } from 'react'; +import { nativeDevices } from '@utils/mockData/device'; +import composeProviders from '@web/helpers/composeProviders'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import useUserContext from '@hooks/useUserContext'; + +const fakeName = 'Tommy Traddles'; + +describe('RoomContext', () => { + const nativeMediaDevices = global.navigator.mediaDevices; + + beforeEach(() => { + Object.defineProperty(global.navigator, 'mediaDevices', { + writable: true, + value: { + enumerateDevices: vi.fn( + () => + new Promise((res) => { + res(nativeDevices); + }) + ), + addEventListener: vi.fn(() => []), + removeEventListener: vi.fn(() => []), + }, + }); + }); + + afterEach(() => { + Object.defineProperty(global.navigator, 'mediaDevices', { + writable: true, + value: nativeMediaDevices, + }); + }); + + it('renders content', async () => { + const TestComponent = () =>
        Test Component
        ; + + await render( + + + + } /> + + + + ); + + expect(screen.getByTestId('test-component')).toBeInTheDocument(); + }); + + it('provides context values to child components', async () => { + const TestComponent = () => { + const { user } = useUserContext(); + + return ( +
        +
        {user.defaultSettings.name}
        +
        + ); + }; + + await render( + + + + } /> + + + + ); + + expect(screen.getByTestId('user-name').textContent).toBe(fakeName); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; +}; + +async function render(ui: ReactElement, { userContext }: RenderOptions = {}) { + const { wrapper: MainWrapper, ...context } = makeTestProvider([providers.user], { + userContext: { + value: { + ...userContext?.value, + defaultSettings: { + publishAudio: true, + publishVideo: true, + name: fakeName, + noiseSuppression: false, + publishCaptions: true, + ...userContext?.value?.defaultSettings, + }, + }, + ...userContext, + }, + }); + + const wrapper = composeProviders(SuspenseBoundary, MainWrapper); + + let result: ReturnType; + + await act(() => { + result = renderBase(ui, { wrapper }); + return Promise.resolve(); + }); + + return { + ...context, + ...result!, + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/user.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/user.spec.tsx new file mode 100644 index 00000000..dd20e2b0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/tests/user.spec.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { act, render, renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import UserProvider, { UserType } from '../user'; +import useUserContext from '../../hooks/useUserContext'; + +const TestComponent = () => { + const { user, setUser } = useUserContext(); + const setUserClickHandler = () => { + if (setUser) { + setUser((prevUserSettings: UserType) => ({ + ...prevUserSettings, + defaultSettings: { + ...prevUserSettings.defaultSettings, + name: 'XXXX', + }, + })); + } + }; + + return ( +
        + {user.defaultSettings.name} + +
        + ); +}; + +describe('UserContext', () => { + it('should be initialized with default values', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const expectedUser = { + defaultSettings: { + publishAudio: true, + publishVideo: true, + name: '', + backgroundFilter: undefined, + noiseSuppression: false, + publishCaptions: true, + }, + issues: { + reconnections: 0, + audioFallbacks: 0, + }, + }; + + const { result } = renderHook(() => useUserContext(), { wrapper }); + const { user, setUser } = result.current; + + expect(user).toEqual(expectedUser); + expect(typeof setUser).toBe('function'); + }); + + it('should be able to update the user', () => { + const { getByTestId } = render( + + + + ); + + // Username can be in localStorage, otherwise it's a UUID with shape User-utcDateTime. + expect(getByTestId('username')).not.toBe('XXXX'); + + act(() => { + getByTestId('update-username').click(); + }); + + expect(getByTestId('username')).toHaveTextContent('XXXX'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/user.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/user.tsx new file mode 100644 index 00000000..5db6f2d0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/Context/user.tsx @@ -0,0 +1,86 @@ +import { + createContext, + useState, + useMemo, + ReactNode, + SetStateAction, + Dispatch, + ReactElement, +} from 'react'; +import { VideoFilter } from '@vonage/client-sdk-video'; +import { getStorageItem, STORAGE_KEYS } from '../utils/storage'; +import { parseVideoFilter } from '../utils/util'; +import type { DeepPartial } from '@common/types'; + +// Define the shape of the User context +export type UserContextType = { + setUser: Dispatch>; // Function to update the user state + user: UserType; // The current user state +}; + +// Define the structure of the User object +export type UserType = { + defaultSettings: { + publishAudio: boolean; // Whether the user is publishing audio + publishVideo: boolean; // Whether the user is publishing video + name: string; // The user's name + noiseSuppression: boolean; // Whether noise suppression is enabled + publishCaptions: boolean; // Whether captions are published + backgroundFilter?: VideoFilter; // The background replacement filter applied to the video + }; + issues: { + reconnections: number; // The number of reconnections the user has experienced + audioFallbacks: number; // The number of times the user's audio has fallen back to a different input + }; + initials?: string; // The user's initials (optional) +}; + +// Create the User context with an initial value of null +export const UserContext = createContext(null); + +export type UserProviderProps = { + children: ReactNode; + value?: DeepPartial; +}; + +/** + * UserProvider component to wrap the application and provide the User preferences to be used by the publisher. + * @param {UserProviderProps} props - The props for the context. + * @returns {ReactElement} a context provider for the User. + */ +const UserProvider = ({ children, value: initialUserState }: UserProviderProps): ReactElement => { + // Load initial settings from local storage + const noiseSuppression = getStorageItem(STORAGE_KEYS.NOISE_SUPPRESSION) === 'true'; + const backgroundFilter = parseVideoFilter(getStorageItem(STORAGE_KEYS.BACKGROUND_REPLACEMENT)); + const name = getStorageItem(STORAGE_KEYS.USERNAME) ?? ''; + + const [user, setUser] = useState(() => ({ + defaultSettings: { + publishAudio: true, + publishVideo: true, + name, + backgroundFilter, + noiseSuppression, + publishCaptions: true, + ...initialUserState?.defaultSettings, + }, + issues: { + reconnections: 0, // Start with zero reconnections + audioFallbacks: 0, // Start with zero audio fallbacks + ...initialUserState?.issues, + }, + })); + + const value = useMemo( + () => ({ + user, + setUser, + }), + [user] + ); + + // Provide the User context to child components + return {children}; +}; + +export default UserProvider; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.spec.tsx new file mode 100644 index 00000000..2c37c887 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.spec.tsx @@ -0,0 +1,52 @@ +import '@web-test/environment/helpers/setupMuiMaterialStylesMock'; + +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import bridge$ from './stores/bridge'; +import { runtime$ } from '@core/stores'; +import VeraRoom from './VeraRoom'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +const renderWithBridge = (ui: React.ReactElement) => + render( + + {ui} + + ); + +beforeEach(() => { + setupWindowNavigatorMock({ + permissions: { + query: Promise.resolve({ + state: 'granted', + addEventListener: () => {}, + } as unknown as PermissionStatus), + }, + }); +}); + +describe('VeraRoom', () => { + it('renders correctly', () => { + renderWithBridge(); + + const veraRoom = screen.getByTestId('vera-room'); + expect(veraRoom).toBeInTheDocument(); + expect(veraRoom).toHaveClass('VeraRoom'); + }); + + it('applies custom className', () => { + // eslint-disable-next-line tailwindcss/no-custom-classname + renderWithBridge(); + + const veraRoom = screen.getByTestId('vera-room'); + expect(veraRoom).toHaveClass('VeraRoom'); + expect(veraRoom).toHaveClass('custom-class'); + }); + + it('passes additional props to the container', () => { + renderWithBridge(); + + const veraRoom = screen.getByTestId('vera-room'); + expect(veraRoom).toHaveAttribute('id', 'test-id'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.stories.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.stories.tsx new file mode 100644 index 00000000..b36f20a5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.stories.tsx @@ -0,0 +1,121 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ComponentProps, memo, PropsWithChildren, useEffect, useRef, forwardRef } from 'react'; + +import '../i18n'; +import './VeraRoomElement'; +import { BridgeAttribute } from './stores/bridge'; +import { env } from '../env'; + +const entryPoint = `${env.API_URL}/v2`; + +type VeraRoomProps = PropsWithChildren< + { + sessionIdentifier: string; + entryPoint: string; + language: string; + } & ComponentProps<'div'> +>; + +const meta: Meta = { + title: 'VeraRoom/VeraRoomElement', + parameters: { + layout: 'fullscreen', + }, + argTypes: { + sessionIdentifier: { + control: 'text', + description: 'Session identifier to join or create', + defaultValue: 'test-123', + }, + entryPoint: { + control: 'text', + description: 'Entry point identifier for logging and tracking', + defaultValue: entryPoint, + }, + language: { + control: 'text', + description: 'BCP-47 language tag (e.g., en, es, it)', + defaultValue: 'en', + }, + }, +}; + +type Story = StoryObj; + +const StableVeraRoom = memo( + forwardRef((props, ref) => { + return ( + + ); + }), + () => true +); + +/** + * Component that renders vera-room and updates attributes imperatively + * without re-renders when props change + */ +function VeraRoomWrapper({ sessionIdentifier, entryPoint, language }: VeraRoomProps) { + const ref = useRef(null); + + // update the html attributes without re-rendering to emulate a vanilla JS environment. + useUpdateHTMLAttribute('session-identifier', sessionIdentifier, ref); + useUpdateHTMLAttribute('entry-point', entryPoint, ref); + useUpdateHTMLAttribute('language', language, ref); + + return ( + + ); +} + +export const Default: Story = { + args: { + sessionIdentifier: 'test-123', + entryPoint: entryPoint, + language: 'en', + }, + render: VeraRoomWrapper, +}; + +export const SpanishLanguage: Story = { + args: { + sessionIdentifier: 'test-spanish-session', + entryPoint: entryPoint, + language: 'es', + }, + render: VeraRoomWrapper, +}; + +export const CustomSession: Story = { + args: { + sessionIdentifier: 'custom-demo-session', + entryPoint: entryPoint, + language: 'en', + }, + render: VeraRoomWrapper, +}; + +function useUpdateHTMLAttribute( + key: BridgeAttribute, + value: string, + customHtmlRef: React.RefObject +) { + useEffect(() => { + if (!customHtmlRef.current) return; + + customHtmlRef.current.setAttribute(key, value); + }, [key, value, customHtmlRef]); +} + +export default meta; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.tsx new file mode 100644 index 00000000..ed9f19be --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoom.tsx @@ -0,0 +1,79 @@ +import { useMemo, useRef, type ComponentProps, type FC } from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import AppContextProvider from '../AppContextProvider'; +import RoomProvider from '@Context/RoomProvider'; +import bridge$ from './stores/bridge'; +import WaitingRoomStage from './stages/WaitingRoomStage'; +import MeetingRoomStage from './stages/MeetingRoomStage'; +import GoodByeStage from './stages/GoodByeStage'; +import useStateSynchronizer from './hooks/useStateSynchronizer'; +import { useMountEffect } from '@web/hooks'; + +type BridgeProps = { + /** Identifier passed in from the `entry-point` HTML attribute. */ + entryPoint?: string; + /** + * Room name to join, passed in from the `session-identifier` HTML attribute. + * If provided, the initial view will be the waiting room for that room. + */ + sessionIdentifier?: string; + /** + * BCP-47 language tag passed in from the `language` HTML attribute. + * Overrides the browser's detected language. + * Example: 'es', 'it', 'en' + */ + language?: string; +}; + +type VeraRoomProps = ComponentProps<'div'> & BridgeProps; + +/** + * VeraRoom + * + * Root React component for the custom element. + * Manages the 3-stage embed flow (waiting room → meeting room → goodbye) + * through a MemoryRouter so navigation is fully isolated from the host page. + * + * Props are supplied by VeraRoomElement on every attribute change; React + * reconciliation ensures that only what changed actually re-renders. + */ +const VeraRoom: FC = ({ className, ...props }) => { + useStateSynchronizer(); + + const container = useMemo(() => { + return globalThis.document.createElement('div'); + }, []); + + const mainContainer = useRef(null); + + const sessionIdentifier = bridge$.use.select((state) => state.sessionIdentifier); + const initialEntry = sessionIdentifier ? `/waiting-room/${sessionIdentifier}` : '/waiting-room'; + + useMountEffect(() => { + mainContainer.current?.appendChild(container); + }); + + return ( + +
        + + + + } /> + {/* Fallback route when no session-identifier attribute is set */} + } /> + } /> + } /> + + + +
        +
        + ); +}; + +export default VeraRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.spec.tsx new file mode 100644 index 00000000..4c848048 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.spec.tsx @@ -0,0 +1,132 @@ +import '@web-test/environment/helpers/setupMuiMaterialStylesMock'; + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; +import VeraRoomElement from './VeraRoomElement'; + +// Mock CSSStyleSheet since JSDOM doesn't support replaceSync +class MockCSSStyleSheet { + replaceSync = vi.fn(); +} + +beforeEach(() => { + vi.stubGlobal('CSSStyleSheet', MockCSSStyleSheet); + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + permissions: { + query: Promise.resolve({ + state: 'granted', + } as unknown as PermissionStatus), + }, + }); +}); + +describe('VeraRoomElement', () => { + it('is registered as a custom element', () => { + expect(customElements.get('vera-room')).toBeDefined(); + }); + + it('has correct tag name', () => { + expect(VeraRoomElement.tagName).toBe('vera-room'); + }); + + it('creates a shadow root when appended to DOM', () => { + const element = document.createElement('vera-room'); + document.body.appendChild(element); + expect(element.shadowRoot).not.toBeNull(); + element.remove(); + }); + + it('has mode open for shadow root', () => { + const element = document.createElement('vera-room'); + document.body.appendChild(element); + expect(element.shadowRoot?.mode).toBe('open'); + element.remove(); + }); + + it('contains a mount div inside shadow root', () => { + const element = document.createElement('vera-room'); + document.body.appendChild(element); + const mountDiv = element.shadowRoot?.querySelector('.vera-room-root'); + expect(mountDiv).not.toBeNull(); + element.remove(); + }); +}); + +it('does nothing when newValue is null (null-string bug guard)', async () => { + // This is the regression case: previously a null newValue could be coerced to the + // string "null" by String(value) in tryParseAttribute, corrupting the bridge state. + const element = new VeraRoomElement(); + document.body.appendChild(element); + + await element.isBridgeReady.promise; + + const partialUpdateSpy = vi.fn(); + element.context!.current.actions.partialUpdate = partialUpdateSpy; + + await element.attributeChangedCallback('session-identifier', 'old-room', null); + expect(partialUpdateSpy).not.toHaveBeenCalled(); + + element.remove(); +}); + +it('does nothing for an unrecognised attribute name', async () => { + const element = new VeraRoomElement(); + document.body.appendChild(element); + + await element.isBridgeReady.promise; + + const partialUpdateSpy = vi.fn(); + element.context!.current.actions.partialUpdate = partialUpdateSpy; + + await element.attributeChangedCallback('data-unknown', null, 'some-value'); + expect(partialUpdateSpy).not.toHaveBeenCalled(); + + element.remove(); +}); + +it('calls partialUpdate with parsed value when attribute changes to a new string', async () => { + const element = new VeraRoomElement(); + document.body.appendChild(element); + + // Wait for isBridgeReady to resolve so context is wired up + await element.isBridgeReady.promise; + + const partialUpdateSpy = vi.fn(); + element.context!.current.actions.partialUpdate = partialUpdateSpy; + + await element.attributeChangedCallback('session-identifier', null, 'new-room'); + expect(partialUpdateSpy).toHaveBeenCalledWith({ sessionIdentifier: 'new-room' }); + + element.remove(); +}); + +it('waits for isBridgeReady before dispatching when bridge is not yet ready', async () => { + // Create but do NOT append to DOM so renderReactTree / bridge never runs + const element = new VeraRoomElement(); + + // Spy by replacing isBridgeReady with a promise that we resolve manually + const { defer } = await import('easy-cancelable-promise'); + const manualDefer = defer(); + // Mark as pending before we resolve + element.isBridgeReady = manualDefer; + + const partialUpdateSpy = vi.fn(); + // Wire up a fake context so we can observe the call + element.context = { + current: { actions: { partialUpdate: partialUpdateSpy }, getState: vi.fn() }, + } as never; + + const callbackPromise = element.attributeChangedCallback('language', null, 'es'); + + // partialUpdate must not have been called yet (bridge not ready) + expect(partialUpdateSpy).not.toHaveBeenCalled(); + + manualDefer.resolve(); + await callbackPromise; + + expect(partialUpdateSpy).toHaveBeenCalledWith({ language: 'es' }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.tsx new file mode 100644 index 00000000..91fbc44d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/VeraRoomElement.tsx @@ -0,0 +1,157 @@ +import ReactDOM from 'react-dom/client'; +import VeraRoom from './VeraRoom'; +import bridge$, { + type BridgeAttribute, + isBridgeAttribute, + bridgeAttributesMap, + bridgeAttributes, + initialState, +} from './stores/bridge'; +import { runtime$ } from '@core/stores'; +import type { Any, KebabToCamel } from '@common/types'; +import { ShadowStylesProvider } from './providers'; +import veraStyles from './styles.css?inline'; +import { defer } from 'easy-cancelable-promise'; +import { BridgeAPI } from './stores/bridge/types'; +import { registerIcon } from '@vonage/vivid'; + +type BridgeState = ReturnType; +type BridgeContext = ReturnType['context']; + +registerIcon(); + +class VeraRoomElement extends HTMLElement { + static tagName = 'vera-room'; + + // React root and shadow + shadow: ShadowRoot; + mount: HTMLDivElement; + root?: ReactDOM.Root; + context?: BridgeContext; + isBridgeReady = defer(); + + constructor() { + super(); + + // will use shadow to have isolated css that will not collisions with the target webpage + this.shadow = this.attachShadow({ mode: 'open' }); + + this.mount = document.createElement('div'); + this.mount.classList.add('vera-room-root'); + + // Use adoptedStyleSheets for Tailwind CSS (processed by Vite) + const tailwindSheet = new CSSStyleSheet(); + tailwindSheet.replaceSync(veraStyles); + this.shadow.adoptedStyleSheets = [tailwindSheet]; + + // Additional host-level styles + const hostStyle = document.createElement('style'); + hostStyle.textContent = ` +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +.vera-room-root { + width: 100%; + height: 100%; + overflow: auto; +} + +:host, .vera-room-root { + min-height: 0; +}`; + + this.shadow.appendChild(hostStyle); + this.shadow.appendChild(this.mount); + } + + connectedCallback() { + if (!this.root) { + this.renderReactTree(); + } + } + + renderReactTree() { + const { wrapper: BridgeProvider, context } = bridge$.Provider.makeProviderWrapper({ + onCreated: () => { + this.isBridgeReady.resolve(); + }, + }); + + this.context = context; + this.root = ReactDOM.createRoot(this.mount); + + const initialState = this.readInitialAttributes(); + + this.root?.render( + + + + + + + + ); + } + + readInitialAttributes() { + const initialValue = bridgeAttributes.reduce((acc, name) => { + const rawValue = this.getAttribute(name); + if (rawValue === null) return acc; + + const value = VeraRoomElement.tryParseAttribute(name, rawValue); + + return { ...acc, ...value }; + }, initialState()) as BridgeState; + + return initialValue; + } + + disconnectedCallback() { + this.root?.unmount(); + this.root = undefined; + } + + static get observedAttributes() { + return bridgeAttributes; + } + + async attributeChangedCallback(name: string, oldValue: unknown, newValue: unknown) { + if (oldValue === newValue || !isBridgeAttribute(name)) return; + if (newValue === null) return; + + const updates = VeraRoomElement.tryParseAttribute(name, newValue); + + if (this.isBridgeReady.isPending()) { + await this.isBridgeReady.promise; + } + + this.context?.current.actions.partialUpdate(updates); + } + + static tryParseAttribute( + name: T, + value: unknown + ): { [K in KebabToCamel]: Any } { + const meta = bridgeAttributesMap[name]; + + const parsed = (() => { + switch (meta.type) { + case 'string': + return String(value); + default: + throw new Error(`Unsupported attribute type for "${name}"`); + } + })(); + + return { [meta.name]: parsed } as { [K in KebabToCamel]: Any }; + } +} + +// Define the custom element +customElements.define(VeraRoomElement.tagName, VeraRoomElement); + +export default VeraRoomElement; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.spec.tsx new file mode 100644 index 00000000..9711d40e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import Example from './Example'; + +describe('Example', () => { + it('renders the header', () => { + render(); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Vera Room'); + expect(screen.getByText('Embeddable Video Conferencing Web Component')).toBeInTheDocument(); + }); + + it('renders the Live Example section', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Live Example' })).toBeInTheDocument(); + }); + + it('renders the Usage section', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Usage' })).toBeInTheDocument(); + expect( + screen.getByText('Add the script to your page and use the custom element:') + ).toBeInTheDocument(); + }); + + it('renders the Styling the Container section', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Styling the Container' })).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.tsx new file mode 100644 index 00000000..241bec2b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/Example.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { env } from '../../env'; + +const Example = () => { + return ( +
        +
        +

        Vera Room

        +

        + Embeddable Video Conferencing Web Component +

        +
        + +
        +
        +

        Live Example

        +

        + The component below is a fully functional video room embedded using the{' '} + <vera-room> custom element. +

        +
        + +
        +
        + +
        +

        Usage

        +

        + Add the script to your page and use the custom element: +

        + + + {``} + {'\n'} + <script{' '} + src= + "room.js" + ></script> + {'\n\n'} + {``} + {'\n'} + <vera-room></vera-room> + + +
        + +
        +

        + Styling the Container +

        +

        + The component fills its container. Set dimensions on the parent or the element itself: +

        + + + <style> + {'\n'} + {' vera-room {\n'} + {' width: 100%;\n'} + {' height: 600px;\n'} + {' }\n'} + </style> + {'\n\n'} + <div{' '} + class= + "video-container" + > + {'\n'} + {' '} + <vera-room></vera-room> + {'\n'} + </div> + + +
        +
        +
        + ); +}; + +function CodeBlock({ children }: { children: React.ReactNode }) { + return ( +
        +
        {children}
        +
        + ); +} + +export default Example; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.css b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.css new file mode 100644 index 00000000..509eea58 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.css @@ -0,0 +1,3 @@ +@import 'tailwindcss'; + +@config '../../../tailwind.config.cjs'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.html b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.html new file mode 100644 index 00000000..cdb1a733 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/index.html @@ -0,0 +1,17 @@ + + + + + + + Vera Room - Example + + +
        + + + + diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/main.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/main.tsx new file mode 100644 index 00000000..46cf6bb4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import Example from './Example'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/vite.example.config.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/vite.example.config.ts new file mode 100644 index 00000000..80a74d7b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/example/vite.example.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, mergeConfig } from 'vite'; +import baseConfigFn from '../../../vite.config'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +// Plugin to rename index.html to example.html +function renameOutputHtml() { + return { + name: 'rename-output-html', + writeBundle() { + const outDir = path.resolve(__dirname, '../../../distRoom'); + const indexPath = path.resolve(outDir, 'index.html'); + const examplePath = path.resolve(outDir, 'example.html'); + + if (fs.existsSync(indexPath)) { + fs.renameSync(indexPath, examplePath); + } + }, + }; +} + +export default defineConfig((env) => { + const baseConfig = baseConfigFn(env); + + return mergeConfig(baseConfig, { + root: __dirname, + base: './', + plugins: [renameOutputHtml()], + build: { + outDir: path.resolve(__dirname, '../../../distRoom'), + emptyOutDir: false, // Don't clear - room.js should already be there + rollupOptions: { + input: path.resolve(__dirname, './index.html'), + }, + }, + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/hooks/useStateSynchronizer.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/hooks/useStateSynchronizer.ts new file mode 100644 index 00000000..6d658aee --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/hooks/useStateSynchronizer.ts @@ -0,0 +1,52 @@ +import { createVideoClient } from '@core/services'; +import i18n from '../../i18n'; +import bridge$ from '../stores/bridge'; +import { runtime$ } from '@core/stores'; +import { useMountEffect } from '@web/hooks'; + +/** + * Syncs the html element with the internal react state + */ +const useStateSynchronizer = () => { + const bridge = bridge$.use.api(); + const runtime = runtime$.use.api(); + const { setLanguage } = runtime$.use.actions(); + + useMountEffect(() => { + const subscriptions = [ + // language changes from the bridge should update i18n and the runtime store + bridge.subscribe( + ({ language }) => language, + (language) => { + void i18n.changeLanguage(language); + setLanguage(language); + }, + { + skipFirst: true, + } + ), + + // clientUrl changes from the bridge should update the video client in the runtime store + bridge.subscribe( + ({ entryPoint }) => entryPoint, + (entryPoint) => { + runtime.setState((state) => ({ + ...state, + videoClient: createVideoClient({ + url: entryPoint, + }), + })); + }, + { + skipFirst: true, + } + ), + ]; + + return () => { + subscriptions.forEach((unsubscribe) => unsubscribe()); + }; + }); +}; + +export default useStateSynchronizer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/index.ts new file mode 100644 index 00000000..7a2f0840 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/index.ts @@ -0,0 +1,5 @@ +// runs interceptors before vonage sdk initialize resources (XHR, navigator.mediaDevices clones, etc) +import '@core/interceptors'; + +import '../i18n'; +import './VeraRoomElement'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/ShadowStylesProvider.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/ShadowStylesProvider.tsx new file mode 100644 index 00000000..2e972171 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/ShadowStylesProvider.tsx @@ -0,0 +1,51 @@ +import type { FC, PropsWithChildren } from 'react'; +import { useMemo } from 'react'; +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; + +type ShadowStylesProviderProps = PropsWithChildren<{ + shadowRoot: ShadowRoot; +}>; + +/** + * ⚠️ Architectural Warning + * + * MUI + Emotion is fundamentally misaligned with this project and this widget. + * + * This widget is: + * - Shadow DOM–based + * - Distributed as an embeddable bundle + * - Expected to be lightweight, isolated, and platform-agnostic + * + * MUI was designed around global DOM assumptions (document.head injection), + * runtime CSS generation, and React-specific styling patterns. + * + * In this environment, that results in: + * - Custom style injection workarounds + * - Manual portal container management + * - Runtime style serialization and hashing overhead + * - Increased bundle size + * - Tight coupling to React + Emotion + * + * Every additional use of MUI here increases architectural friction + * and long-term maintenance cost. + * + * Do not introduce new MUI usage in this module. + */ +const ShadowStylesProvider: FC = ({ shadowRoot, children }) => { + const emotionCache = useMemo(() => { + const container = document.createElement('div'); + container.setAttribute('data-emotion-container', 'vera'); + shadowRoot.prepend(container); + + return createCache({ + key: 'vera', + container, + prepend: true, + }); + }, [shadowRoot]); + + return {children}; +}; + +export default ShadowStylesProvider; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/index.ts new file mode 100644 index 00000000..24e25b4d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/providers/index.ts @@ -0,0 +1 @@ +export { default as ShadowStylesProvider } from './ShadowStylesProvider'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.spec.tsx new file mode 100644 index 00000000..99e1964e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.spec.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import useGoodByePage from '@hooks/useGoodByePage'; +import GoodByeStage from './GoodByeStage'; + +vi.mock('@hooks/useGoodByePage'); + +vi.mock('@components/GoodBye/ArchiveList', () => ({ + default: ({ archives }: { archives: unknown[] | 'error' }) => ( +
        + ), +})); + +vi.mock('@components/GoodBye/GoodbyeMessage', () => ({ + default: ({ header, message }: { header: string; message: string }) => ( +
        + ), +})); + +vi.mock('@components/GoodBye/ReenterRoomButton', () => ({ + default: () =>
        , +})); + +vi.mock('@ui', async () => { + const actual = await vi.importActual('@ui'); + return { + ...actual, + Card: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
        {children}
        + ), + PageLayoutEmbed: Object.assign( + ({ children }: { children: React.ReactNode }) =>
        {children}
        , + { + Left: ({ children }: { children: React.ReactNode }) =>
        {children}
        , + Right: ({ children }: { children: React.ReactNode }) =>
        {children}
        , + } + ), + }; +}); + +const mockUseGoodByePage = useGoodByePage as Mock; + +describe('GoodByeStage', () => { + beforeEach(() => { + mockUseGoodByePage.mockReturnValue({ + sessionKey: 'test-session-key', + archives: [], + header: 'You have left the meeting', + caption: 'Thank you for joining!', + }); + }); + + it('renders the goodbye message with header and caption from the hook', () => { + renderStage(); + const message = screen.getByTestId('goodbye-message'); + expect(message).toHaveAttribute('data-header', 'You have left the meeting'); + expect(message).toHaveAttribute('data-message', 'Thank you for joining!'); + }); + + it('renders the ReenterRoomButton', () => { + renderStage(); + expect(screen.getByTestId('reenter-room-button')).toBeInTheDocument(); + }); + + it('renders the ArchiveList with archives from the hook', () => { + const archives = [{ id: 'archive-1', status: 'available' }]; + mockUseGoodByePage.mockReturnValue({ + sessionKey: 'test-session-key', + archives, + header: 'Gone', + caption: 'Bye', + }); + + renderStage(); + const archiveList = screen.getByTestId('archive-list'); + expect(archiveList).toHaveAttribute('data-value', JSON.stringify(archives)); + }); + + it('renders the ArchiveList with "error" when archives failed to load', () => { + mockUseGoodByePage.mockReturnValue({ + sessionKey: 'test-session-key', + archives: 'error', + header: 'Gone', + caption: 'Bye', + }); + + renderStage(); + const archiveList = screen.getByTestId('archive-list'); + expect(archiveList).toHaveAttribute('data-value', '"error"'); + }); + + it('renders the archive list label', () => { + renderStage(); + // The label is rendered from i18n key 'archiveList.label' which in test env falls back to the key + expect(screen.getByTestId('archive-list')).toBeInTheDocument(); + }); +}); + +function renderStage() { + render( + + + + ); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.tsx new file mode 100644 index 00000000..4bd66f1d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/GoodByeStage.tsx @@ -0,0 +1,49 @@ +import ArchiveList from '@components/GoodBye/ArchiveList'; +import GoodByeMessage from '@components/GoodBye/GoodbyeMessage'; +import ReenterRoomButton from '@components/GoodBye/ReenterRoomButton'; +import useGoodByePage from '@hooks/useGoodByePage'; +import { Card, PageLayoutEmbed } from '@ui'; +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * GoodByeStage + * + * Embeddable version of the goodbye screen. Equivalent to GoodBye but without + * the Vera chrome (Banner, Footer) and without the GoToLandingPageButton since + * there is no landing page in the embed context. + * + * Re-enter the room button navigates back to /waiting-room/:roomName via + * the parent MemoryRouter in VeraRoom. + */ +const GoodByeStage: FC = () => { + const { t } = useTranslation(); + const { archives, header, caption } = useGoodByePage(); + + return ( + + + + + +
        + +

        + {t('goodBye.title')} +

        + +
        + + +

        + {t('archiveList.label')} +

        + +
        +
        +
        +
        + ); +}; + +export default GoodByeStage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/index.ts new file mode 100644 index 00000000..15c55fe0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/GoodByeStage/index.ts @@ -0,0 +1 @@ +export { default } from './GoodByeStage'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.spec.tsx new file mode 100644 index 00000000..0507388e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.spec.tsx @@ -0,0 +1,62 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactElement } from 'react'; +import MeetingRoomStage from './MeetingRoomStage'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@vonage/client-sdk-video'); + +vi.mock('@pages/MeetingRoom', () => ({ + default: (props: Record) => ( +
        + ), +})); + +type RenderOptions = { + sessionContext?: ProviderOptions['SessionContext']; + userContext?: ProviderOptions['UserContext']; +}; + +function render(ui: ReactElement, { sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} + +describe('MeetingRoomStage', () => { + beforeEach(() => { + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + + vi.spyOn(globalThis.navigator.permissions, 'query').mockResolvedValue({ + state: 'granted', + } as PermissionStatus); + }); + + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('meeting-room')).toBeInTheDocument(); + }); + + it('passes h-full w-full className to MeetingRoom', () => { + render(); + const meetingRoom = screen.getByTestId('meeting-room'); + expect(meetingRoom).toHaveAttribute('data-classname', 'h-full w-full'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.tsx new file mode 100644 index 00000000..606d3693 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/MeetingRoomStage.tsx @@ -0,0 +1,19 @@ +import type { FC } from 'react'; +import SessionProvider from '@Context/SessionProvider/session'; +import { PublisherProvider } from '@Context/PublisherProvider'; +import MeetingRoom from '@pages/MeetingRoom'; + +/** + * MeetingRoomStage + * + * Embeddable version of the meeting room. Provides SessionProvider and PublisherProvider. + */ +const MeetingRoomStage: FC = () => ( + + + + + +); + +export default MeetingRoomStage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/index.ts new file mode 100644 index 00000000..f3e7f42b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/MeetingRoomStage/index.ts @@ -0,0 +1 @@ +export { default } from './MeetingRoomStage'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.spec.tsx new file mode 100644 index 00000000..d325ceaf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.spec.tsx @@ -0,0 +1,147 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactElement } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import bridge$ from '../../stores/bridge'; +import WaitingRoomStage from './WaitingRoomStage'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import { env } from '../../../env'; + +vi.mock('@vonage/client-sdk-video'); +vi.mock('@utils/waitUntilPlaying/waitUntilPlaying', () => ({ default: () => Promise.resolve() })); + +vi.mock('@hooks/usePermissions', () => ({ + default: () => ({ + accessStatus: DEVICE_ACCESS_STATUS.ACCEPTED, + setAccessStatus: vi.fn(), + }), +})); + +vi.mock('@hooks/useBackgroundPublisherContext', () => ({ + default: () => ({ + initBackgroundLocalPublisher: vi.fn(), + publisher: null, + }), +})); + +vi.mock('@components/WaitingRoom/VideoContainer', () => ({ + default: () =>
        , +})); + +vi.mock('@components/WaitingRoom/VideoContainer/VideoContainer.skeleton', () => ({ + default: () =>
        , +})); + +vi.mock('@components/WaitingRoom/ControlPanel', () => ({ + default: () =>
        , +})); + +vi.mock('@components/WaitingRoom/UserNameInput', () => ({ + default: () =>
        , +})); + +vi.mock('@components/WaitingRoom/UserNameInput/UserNameInput.skeleton', () => ({ + default: () =>
        , +})); + +vi.mock('@components/DeviceAccessAlert', () => ({ + default: () =>
        , +})); + +vi.mock('@ui', async () => { + const actual = await vi.importActual('@ui'); + return { + ...actual, + PageLayoutEmbed: Object.assign( + ({ children }: { children: React.ReactNode }) =>
        {children}
        , + { + Left: ({ children }: { children: React.ReactNode }) =>
        {children}
        , + Right: ({ children }: { children: React.ReactNode }) =>
        {children}
        , + } + ), + }; +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + initialRoute?: string; + sessionIdentifier?: string; +}; + +function SetBridgeState({ sessionIdentifier }: { sessionIdentifier?: string }) { + const actions = bridge$.use.actions(); + if (sessionIdentifier) { + actions.partialUpdate({ sessionIdentifier }); + } + return null; +} + +function render( + ui: ReactElement, + { + userContext, + initialRoute = '/waiting-room/my-room', + sessionIdentifier = 'my-room', + }: RenderOptions = {} +) { + const { wrapper: ProvidersWrapper, ...context } = makeTestProvider([providers.user], { + userContext, + }); + + return { + ...context, + ...renderBase( + + + + + + + + } /> + + + + + ), + }; +} + +describe('WaitingRoomStage', () => { + beforeEach(() => { + vi.clearAllMocks(); + env.WAITING_ROOM_ALLOW_DEVICE_SELECTION = true; + localStorage.setItem('videoSourceEnabled', 'false'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('renders content when sessionIdentifier is set', () => { + render(, { sessionIdentifier: 'my-room' }); + expect(screen.getByTestId('video-container')).toBeInTheDocument(); + expect(screen.getByTestId('username-input')).toBeInTheDocument(); + expect(screen.getByTestId('control-panel')).toBeInTheDocument(); + }); + + it('renders skeletons when isRoomReady is false', () => { + localStorage.setItem('videoSourceEnabled', 'true'); + render(, { sessionIdentifier: 'my-room' }); + + expect(screen.getByTestId('video-container-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId('username-input-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('video-container')).not.toBeInTheDocument(); + }); + + it('shows config error message when no sessionIdentifier is set', () => { + render(, { initialRoute: '/waiting-room', sessionIdentifier: '' }); + expect(screen.getByText(/session-identifier/i)).toBeInTheDocument(); + }); + + it('renders content when bridge has a sessionIdentifier', () => { + render(, { sessionIdentifier: 'bridge-room' }); + expect(screen.getByTestId('video-container')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.tsx new file mode 100644 index 00000000..ecab056c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/WaitingRoomStage.tsx @@ -0,0 +1,128 @@ +import { type FC } from 'react'; +import { Navigate } from 'react-router-dom'; +import Typography from '@mui/material/Typography'; +import bridge$ from '../../stores/bridge'; +import { PreviewPublisherProvider } from '@Context/PreviewPublisherProvider'; +import backgroundEffectsDialog$ from '@Context/BackgroundEffectsDialog'; +import precallNetworkTestDialog$ from '@Context/PrecallNetworkTestDialog'; +import useWaitingRoom from '@hooks/useWaitingRoom'; +import { Box } from '@mui/material'; +import DeviceAccessAlert from '@components/DeviceAccessAlert'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import UsernameInputSkeleton from '@components/WaitingRoom/UserNameInput/UserNameInput.skeleton'; +import UsernameInput from '@components/WaitingRoom/UserNameInput'; +import VideoContainerSkeleton from '@components/WaitingRoom/VideoContainer/VideoContainer.skeleton'; +import ControlPanel from '@components/WaitingRoom/ControlPanel'; +import VideoContainer from '@components/WaitingRoom/VideoContainer'; +import { PageLayoutEmbed } from '@ui'; + +/** + * WaitingRoomStage + * + * Embeddable version of the waiting room. Equivalent to WaitingRoom but without + * the Vera chrome (Banner, Footer). Provides its own PreviewPublisherProvider. + * + * Navigation to the meeting room is handled by UsernameInput via react-router-dom, + * which resolves against the parent MemoryRouter in VeraRoom. + * + * If mounted at /waiting-room (no :roomIdentifier param) but bridge$ has a sessionIdentifier, + * redirects to /waiting-room/:sessionIdentifier so useRoomName() resolves correctly. + */ +const WaitingRoomStage: FC = () => { + const sessionIdentifier = bridge$.use.select((state) => state.sessionIdentifier); + + const missingRoomIdentifier = !sessionIdentifier; + const canRedirect = missingRoomIdentifier && !!sessionIdentifier; + const isConfigError = missingRoomIdentifier && !sessionIdentifier; + + if (canRedirect) { + return ; + } + + if (isConfigError) { + return ( +
        + + Set the session-identifier attribute to specify the room to join. + +
        + ); + } + + return ( + + + + ); +}; + +function WaitingRoomStageContent() { + const { + anchorEl, + openAudioInput, + openVideoInput, + openAudioOutput, + username, + setUsername, + accessStatus, + isRoomReady, + roomName, + handleAudioInputOpen, + handleVideoInputOpen, + handleAudioOutputOpen, + handleClose, + } = useWaitingRoom(); + + return ( + + + + + + + {isRoomReady && ( + <> + + + + + )} + + {!isRoomReady && } + + + + + {isRoomReady && ( + + )} + + {!isRoomReady && } + + + {accessStatus !== DEVICE_ACCESS_STATUS.ACCEPTED && ( + + )} + + + + ); +} + +export default WaitingRoomStage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/index.ts new file mode 100644 index 00000000..29f1f2aa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stages/WaitingRoomStage/index.ts @@ -0,0 +1 @@ +export { default } from './WaitingRoomStage'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/bridge$.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/bridge$.ts new file mode 100644 index 00000000..8417c012 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/bridge$.ts @@ -0,0 +1,26 @@ +import { createContext, type InferAPI } from 'react-global-state-hooks'; +import { initialState, metadata } from './constants'; + +export type BridgeAPI = InferAPI; + +type BridgeState = ReturnType; + +/** + * This store contains the state and actions related to the bridge between the VeraRoom web component and the React application. + */ +const bridge$ = createContext(initialState, { + name: 'bridge', + metadata, + actions: { + /** + * Merges the provided attribute values into the bridge state. + */ + partialUpdate(attributes: Partial) { + return ({ setState }) => { + setState((state) => ({ ...state, ...attributes })); + }; + }, + }, +}); + +export default bridge$; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributes.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributes.ts new file mode 100644 index 00000000..df029141 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributes.ts @@ -0,0 +1,3 @@ +const bridgeAttributes = ['entry-point', 'session-identifier', 'language'] as const; + +export default bridgeAttributes; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributesMap.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributesMap.ts new file mode 100644 index 00000000..dc6221bb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/bridgeAttributesMap.ts @@ -0,0 +1,33 @@ +import { BridgeAttributeMeta } from '../types'; + +const bridgeAttributesMap = { + /** + * The entry point for the bridge, used to identify the source of the session and for logging purposes. + */ + 'entry-point': new BridgeAttributeMeta({ + key: 'entry-point', + type: 'string', + default: '', + }), + + /** + * If provided joins directly to the session with the provided session identifier, otherwise a new session may be created + */ + 'session-identifier': new BridgeAttributeMeta({ + key: 'session-identifier', + type: 'string', + default: '', + }), + + /** + * BCP-47 language tag for the UI locale (e.g. 'en', 'es', 'it'). + * Falls back to the browser's detected language when not provided. + */ + language: new BridgeAttributeMeta({ + key: 'language', + type: 'string', + default: '', + }), +} as const; + +export default bridgeAttributesMap; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/index.ts new file mode 100644 index 00000000..84cd5c1b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/index.ts @@ -0,0 +1,4 @@ +export { default as initialState } from './initialState'; +export { default as metadata } from './metadata'; +export { default as bridgeAttributes } from './bridgeAttributes'; +export { default as bridgeAttributesMap } from './bridgeAttributesMap'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/initialState.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/initialState.ts new file mode 100644 index 00000000..bdc9655e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/initialState.ts @@ -0,0 +1,18 @@ +import type { KebabToCamel } from '@common/types'; +import bridgeAttributesMap from './bridgeAttributesMap'; + +function initialValue() { + const htmlAttributes: { + [Key in keyof typeof bridgeAttributesMap as KebabToCamel]: string; + } = { + entryPoint: bridgeAttributesMap['entry-point'].value, + sessionIdentifier: bridgeAttributesMap['session-identifier'].value, + language: bridgeAttributesMap['language'].value, + }; + + return { + ...htmlAttributes, + }; +} + +export default initialValue; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/metadata.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/metadata.ts new file mode 100644 index 00000000..6b899de5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/constants/metadata.ts @@ -0,0 +1,7 @@ +function metadata() { + return { + isConnected: false, + }; +} + +export default metadata; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/index.ts new file mode 100644 index 00000000..7991ec64 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/index.ts @@ -0,0 +1,4 @@ +export { default } from './bridge$'; +export * from './schemas'; +export * from './types'; +export * from './constants'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/BridgeAttribute.schema.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/BridgeAttribute.schema.ts new file mode 100644 index 00000000..7208fa48 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/BridgeAttribute.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { bridgeAttributes } from '../constants'; +import type { BridgeAttribute } from '../types'; + +const bridgeAttributeSchema: z.ZodType = z.enum(bridgeAttributes); + +export function assertBridgeAttribute(value: unknown): asserts value is BridgeAttribute { + bridgeAttributeSchema.parse(value); +} + +export function isBridgeAttribute(value: unknown): value is BridgeAttribute { + return bridgeAttributeSchema.safeParse(value).success; +} + +export default bridgeAttributeSchema; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/index.ts new file mode 100644 index 00000000..d27549eb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/schemas/index.ts @@ -0,0 +1 @@ +export * from './BridgeAttribute.schema'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAPI.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAPI.ts new file mode 100644 index 00000000..87c368ac --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAPI.ts @@ -0,0 +1,3 @@ +export type BridgeAPI = import('../bridge$').BridgeAPI; + +export default BridgeAPI; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttribute.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttribute.ts new file mode 100644 index 00000000..2498b4bf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttribute.ts @@ -0,0 +1,5 @@ +import type { bridgeAttributes } from '../constants'; + +export type BridgeAttribute = (typeof bridgeAttributes)[number]; + +export default BridgeAttribute; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeMeta.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeMeta.ts new file mode 100644 index 00000000..71b488e8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeMeta.ts @@ -0,0 +1,26 @@ +import type { KebabToCamel } from '@common/types'; +import type BridgeAttribute from './BridgeAttribute'; +import type BridgeAttributeType from './BridgeAttributeType'; +import { kebabToCamel } from '@common/helpers'; + +/** + * HTML Attribute metadata + */ +export class BridgeAttributeMeta< + Attribute extends BridgeAttribute, + Type extends BridgeAttributeType, +> { + htmlKey: Attribute; + name: KebabToCamel; + value: string; + type: Type; + + constructor(args: { key: Attribute; default: string; type: Type }) { + this.htmlKey = args.key; + this.name = kebabToCamel(args.key) as KebabToCamel; + this.value = args.default; + this.type = args.type; + } +} + +export default BridgeAttributeMeta; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeType.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeType.ts new file mode 100644 index 00000000..1845da9b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/BridgeAttributeType.ts @@ -0,0 +1,3 @@ +export type BridgeAttributeType = 'string' | 'number' | 'boolean'; + +export default BridgeAttributeType; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/index.ts new file mode 100644 index 00000000..7ae0c870 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/bridge/types/index.ts @@ -0,0 +1,4 @@ +export * from './BridgeAPI'; +export * from './BridgeAttribute'; +export * from './BridgeAttributeMeta'; +export * from './BridgeAttributeType'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/index.ts new file mode 100644 index 00000000..38179330 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/stores/index.ts @@ -0,0 +1 @@ +export { default as bridge$ } from './bridge'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/styles.css b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/styles.css new file mode 100644 index 00000000..f9b48a6e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/styles.css @@ -0,0 +1,36 @@ +@import 'tailwindcss'; + +/* Keeps output pixel exact after migration from muiV5 to muiV9 */ +@layer base { + * { + letter-spacing: 0.15008px; + -webkit-font-smoothing: antialiased; + } +} + +/* Tailwind CSS v4 config directive */ +@config '../../tailwind.config.cjs'; + +/* Libs source - outside frontend, needs explicit @source */ +@source "../../../libs/ui/**/*.tsx"; + +/* base app styles */ +@import '../css/App.css'; + +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/vite.veraroom.config.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/vite.veraroom.config.ts new file mode 100644 index 00000000..c491aa78 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/VeraRoom/vite.veraroom.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vite'; +import baseConfigFn from '../../vite.config'; +import * as path from 'node:path'; + +export default defineConfig((env) => { + const baseConfig = baseConfigFn(env); + + return mergeConfig(baseConfig, { + base: './', + define: { + 'process.env.NODE_ENV': '"production"', + }, + esbuild: { + jsxDev: false, + }, + build: { + outDir: path.resolve(__dirname, '../../distRoom'), + emptyOutDir: true, + + lib: { + entry: path.resolve(__dirname, './index.ts'), + name: 'VeraRoom', + formats: ['iife'], + fileName: () => 'room.js', + }, + + rollupOptions: { + output: { + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.spec.ts new file mode 100644 index 00000000..5e780574 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serverArchives } from '../tests/data'; +import getArchives from './getArchives'; +import type { ServerArchive } from '../model'; +import type { VideoClient } from '@core/services'; + +const mockSearchArchivesQuery = vi.fn((_args?: unknown) => + Promise.resolve({ items: [] as ServerArchive[] }) +); + +const mockVideoClient = { + searchArchives: (...args: unknown[]) => mockSearchArchivesQuery(...args), +} as unknown as VideoClient; + +describe('getArchives', () => { + it('returns an object with array of Archives and hasPending flag', async () => { + mockSearchArchivesQuery.mockResolvedValue({ items: serverArchives }); + const archives = await getArchives({ + locale: 'en', + sessionKey: 'test-session-id', + videoClient: mockVideoClient, + }); + expect(archives).toEqual({ + archives: [ + { + createdAt: 1725268594000, + createdAtFormatted: 'Mon, Sep 2 5:16 AM', + duration: 0, + id: 'dc91ede6-0d1a-4de6-90d8-692c2113b34a', + size: 0, + status: 'pending', + url: null, + }, + { + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + duration: 56, + id: 'c32509e3-24a9-4d1f-98a0-66a0f0fdbca6', + size: 278545, + status: 'available', + url: 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4', + }, + { + createdAt: 1725268111000, + createdAtFormatted: 'Mon, Sep 2 5:08 AM', + duration: 13, + id: '88417e46-6459-435b-b1a4-8524db79946c', + size: 911104, + status: 'failed', + url: null, + }, + ], + hasPending: true, + }); + }); + + it('returns object with empty array when no archives', async () => { + mockSearchArchivesQuery.mockResolvedValue({ items: [] }); + const archives = await getArchives({ + locale: 'en', + sessionKey: 'test-session-id', + videoClient: mockVideoClient, + }); + expect(archives).toEqual({ + archives: [], + hasPending: false, + }); + }); + + it('throws with error when api call throws', async () => { + mockSearchArchivesQuery.mockRejectedValue(new Error('Network Error')); + await expect( + getArchives({ locale: 'en', sessionKey: 'test-session-id', videoClient: mockVideoClient }) + ).rejects.toThrowError(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.ts new file mode 100644 index 00000000..5ac165b3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/getArchives.ts @@ -0,0 +1,45 @@ +import { type VideoClient } from '@core/services'; +import { Archive, createArchiveFromServer, hasPending, type ServerArchive } from '../model'; + +export type ArchiveResponse = { + archives: Archive[]; + hasPending: boolean; +}; + +/** + * Returns a list of archives and the status of the archives for a given meeting room. + * @param {string} locale - current locale + * @param {string} sessionId - The session ID to search archives for + * @param {VideoClient} videoClient - The video client instance to use for searching archives + * @returns {Promise} The archives from the meeting room (if any) and whether any archives are pending. + */ +const getArchives = async ({ + locale, + sessionKey, + videoClient, +}: { + locale: string; + sessionKey: string; + videoClient: VideoClient; +}): Promise => { + const response = await videoClient.searchArchives({ sessionKey }); + const archivesFromServer = response?.items; + + if (archivesFromServer instanceof Array) { + const archives = archivesFromServer.map((archiveFromServer) => { + return createArchiveFromServer(locale, archiveFromServer as ServerArchive); + }); + + return { + archives, + hasPending: hasPending(archives), + }; + } + + return { + archives: [], + hasPending: false, + }; +}; + +export default getArchives; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/index.ts new file mode 100644 index 00000000..1353304c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/getArchives/index.ts @@ -0,0 +1 @@ +export { default as getArchives, type ArchiveResponse } from './getArchives'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/index.ts new file mode 100644 index 00000000..8b13c86b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/index.ts @@ -0,0 +1 @@ +export { getArchives, type ArchiveResponse } from './getArchives'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/model.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/model.ts new file mode 100644 index 00000000..86faf87b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/model.ts @@ -0,0 +1,76 @@ +import { getFormattedDate, getFormattedTime } from '../../utils/dateTime'; + +export type ArchiveStatus = 'available' | 'pending' | 'failed'; +export type ServerArchiveStatus = + | 'available' + | 'expired' + | 'failed' + | 'paused' + | 'started' + | 'stopped' + | 'uploaded'; + +export interface ServerArchive { + id: string; + url: string | null; + status: ServerArchiveStatus; + createdAt: number; + duration?: number; + size?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [others: string]: any; +} + +export type Archive = { + id: string; + url: string | null; + status: ArchiveStatus; + createdAt: number; + createdAtFormatted: string; + duration?: number; + size?: number; +}; + +const getDateString = (locale: string, timestamp: number) => { + return `${getFormattedDate(locale, timestamp)} ${getFormattedTime(locale, timestamp)}`; +}; + +const getArchiveStatus = (status: ServerArchiveStatus): ArchiveStatus => { + switch (status) { + case 'available': + return 'available'; + case 'started': + case 'stopped': + case 'uploaded': + case 'paused': + return 'pending'; + default: + return 'failed'; + } +}; + +/** + * Modifies an archive retrieved from the server to be easily consumable. + * @param {string} locale - current locale + * @param {ServerArchive} serverArchive - The archive to be modified. + * @returns {Archive} The modified archive. + */ +export const createArchiveFromServer = (locale: string, serverArchive: ServerArchive): Archive => { + return { + id: serverArchive.id, + url: serverArchive.url, + status: getArchiveStatus(serverArchive.status), + createdAt: serverArchive.createdAt, + createdAtFormatted: getDateString(locale, serverArchive.createdAt), + duration: serverArchive.duration, + size: serverArchive.size, + }; +}; + +/** + * Checks if any of the archives are pending. + * @param {Archive[]} archives - The archives to check. + * @returns {boolean} Returns `true` if any archives are pending, else returns `false`. + */ +export const hasPending = (archives: Archive[]): boolean => + archives.some((archive) => archive.status === 'pending'); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/data.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/data.ts new file mode 100644 index 00000000..cc0e5949 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/data.ts @@ -0,0 +1,110 @@ +import { Archive, ServerArchive } from '../model'; + +export const startedServerArchive: ServerArchive = { + id: 'dc91ede6-0d1a-4de6-90d8-692c2113b34a', + status: 'started', + name: '', + reason: '', + sessionId: '2_MX40Njk2OTE2NH5-MTcyNTI2ODA5Mjc2OH5FMk8vWlE1Wkp6alVNR2xoQ1VveTZzNk1-fn4', + applicationId: '46969164', + createdAt: 1725268594000, + size: 0, + duration: 0, + outputMode: 'composed', + streamMode: 'auto', + hasAudio: true, + hasVideo: true, + hasTranscription: false, + sha256sum: '', + password: '', + updatedAt: 1725268594000, + multiArchiveTag: '', + event: 'archive', + partnerId: 46969164, + projectId: 46969164, + resolution: '640x480', + url: null, +}; + +export const pendingArchive: Archive = { + createdAt: 1725268594000, + createdAtFormatted: 'Mon, Sep 2 5:16 AM', + id: 'dc91ede6-0d1a-4de6-90d8-692c2113b34a', + status: 'pending', + url: null, +}; + +export const availableServerArchive: ServerArchive = { + id: 'c32509e3-24a9-4d1f-98a0-66a0f0fdbca6', + status: 'available', + name: '', + reason: 'user initiated', + sessionId: '2_MX40Njk2OTE2NH5-MTcyNTI2ODA5Mjc2OH5FMk8vWlE1Wkp6alVNR2xoQ1VveTZzNk1-fn4', + applicationId: '46969164', + createdAt: 1725268141000, + size: 278545, + duration: 56, + outputMode: 'composed', + streamMode: 'auto', + hasAudio: true, + hasVideo: true, + hasTranscription: false, + sha256sum: 'fEzap6vpxmzvZj4l+aZ4PNDK1MOz9gxZ4eRmUpsPECk=', + password: '', + updatedAt: 1725268199000, + multiArchiveTag: '', + event: 'archive', + partnerId: 46969164, + projectId: 46969164, + resolution: '640x480', + url: 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4', +}; +export const availableArchive: Archive = { + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + id: 'c32509e3-24a9-4d1f-98a0-66a0f0fdbca6', + status: 'available', + url: 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4', +}; + +export const failedServerArchive: ServerArchive = { + id: '88417e46-6459-435b-b1a4-8524db79946c', + status: 'failed', + name: '', + reason: 'user initiated', + sessionId: '2_MX40Njk2OTE2NH5-MTcyNTI2ODA5Mjc2OH5FMk8vWlE1Wkp6alVNR2xoQ1VveTZzNk1-fn4', + applicationId: '46969164', + createdAt: 1725268111000, + size: 911104, + duration: 13, + outputMode: 'composed', + streamMode: 'auto', + hasAudio: true, + hasVideo: true, + hasTranscription: false, + sha256sum: 'W6t88jhGPfucqChZOAdM47U8wiY8eubDBS2BdqsfUeM=', + password: '', + updatedAt: 1725268127000, + multiArchiveTag: '', + event: 'archive', + partnerId: 46969164, + projectId: 46969164, + resolution: '640x480', + url: null, +}; + +export const failedArchive: Archive = { + createdAt: 1725268111000, + createdAtFormatted: 'Mon, Sep 2 5:08 AM', + id: '88417e46-6459-435b-b1a4-8524db79946c', + status: 'failed', + url: null, +}; + +export const archives: Archive[] = [pendingArchive, availableArchive, failedArchive]; + +export const serverArchives: ServerArchive[] = [ + startedServerArchive, + availableServerArchive, + failedServerArchive, +]; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/index.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/index.spec.ts new file mode 100644 index 00000000..c9d3efc5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/index.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; +import { serverArchives } from './data'; +import { getArchives } from '..'; +import type { ServerArchive } from '../model'; +import type { VideoClient } from '@core/services'; + +const mockSearchArchivesQuery = vi.fn((_args?: unknown) => + Promise.resolve({ items: [] as ServerArchive[] }) +); + +const mockVideoClient = { + searchArchives: (...args: unknown[]) => mockSearchArchivesQuery(...args), +} as unknown as VideoClient; + +describe('getArchives', () => { + it('it returns an object with array of Archives and hasPending flag', async () => { + mockSearchArchivesQuery.mockResolvedValue({ items: serverArchives }); + const archives = await getArchives({ + locale: 'en', + sessionKey: 'test-session-id', + videoClient: mockVideoClient, + }); + expect(archives).toEqual({ + archives: [ + { + createdAt: 1725268594000, + createdAtFormatted: 'Mon, Sep 2 5:16 AM', + duration: 0, + id: 'dc91ede6-0d1a-4de6-90d8-692c2113b34a', + size: 0, + status: 'pending', + url: null, + }, + { + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + duration: 56, + id: 'c32509e3-24a9-4d1f-98a0-66a0f0fdbca6', + size: 278545, + status: 'available', + url: 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4', + }, + { + createdAt: 1725268111000, + createdAtFormatted: 'Mon, Sep 2 5:08 AM', + duration: 13, + id: '88417e46-6459-435b-b1a4-8524db79946c', + size: 911104, + status: 'failed', + url: null, + }, + ], + hasPending: true, + }); + }); + + it('it returns object with empty array when no archives', async () => { + mockSearchArchivesQuery.mockResolvedValue({ items: [] }); + const archives = await getArchives({ + locale: 'en', + sessionKey: 'test-session-id', + videoClient: mockVideoClient, + }); + expect(archives).toEqual({ + archives: [], + hasPending: false, + }); + }); + + it('it throws with error when api call throws', async () => { + mockSearchArchivesQuery.mockRejectedValue(new Error('Network Error')); + await expect( + getArchives({ locale: 'en', sessionKey: 'test-session-id', videoClient: mockVideoClient }) + ).rejects.toThrowError(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/model.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/model.spec.ts new file mode 100644 index 00000000..e4e14a85 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/archiving/tests/model.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { createArchiveFromServer, hasPending } from '../model'; +import { availableServerArchive, failedServerArchive, startedServerArchive } from './data'; + +describe('createArchiveFromServer', () => { + it('should convert fields to model fields', () => { + const archive = createArchiveFromServer('en', availableServerArchive); + expect(archive).toEqual({ + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + duration: 56, + id: 'c32509e3-24a9-4d1f-98a0-66a0f0fdbca6', + size: 278545, + status: 'available', + url: 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4', + }); + }); + + it('should return status as failed for failed and expired archives', () => { + expect(createArchiveFromServer('en', failedServerArchive).status).toBe('failed'); + expect( + createArchiveFromServer('en', { ...failedServerArchive, status: 'expired' }).status + ).toBe('failed'); + }); + + it('should return available as failed for status available archives', () => { + expect(createArchiveFromServer('en', availableServerArchive).status).toBe('available'); + }); + + it('should return status as pending for started, stopped, uploaded, and paused archives', () => { + expect(createArchiveFromServer('en', startedServerArchive).status).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'stopped' }).status + ).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'uploaded' }).status + ).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'paused' }).status + ).toBe('pending'); + }); +}); + +describe('hasPending', () => { + it('should return true if any archive is pending', () => { + expect( + hasPending([ + { + id: '1', + url: 'example.com', + status: 'available', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + { + id: '1', + url: 'example.com', + status: 'pending', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + { + id: '1', + url: 'example.com', + status: 'available', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + ]) + ).toBe(true); + }); + it('should return false if no archives are pending', () => { + expect( + hasPending([ + { + id: '1', + url: 'example.com', + status: 'available', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + { + id: '1', + url: 'example.com', + status: 'failed', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + { + id: '1', + url: 'example.com', + status: 'available', + createdAt: 1725268141000, + createdAtFormatted: 'Mon, Sep 2 5:09 AM', + }, + ]) + ).toBe(false); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/fetchCredentials.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/fetchCredentials.ts new file mode 100644 index 00000000..7fe115cd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/fetchCredentials.ts @@ -0,0 +1,21 @@ +import axios from 'axios'; +import { API_URL } from '../utils/constants'; +import type { Credential } from '@app-types/session'; + +/** + * @typedef CredentialsType + * @property {string} sessionId - the ID of the session (i.e., video call) to join + * @property {string} token - authenticates the user to the session (only users with valid tokens may join a session) + * @property {string} apiKey - your API key + */ + +/** + * Returns the credentials needed to enter video call + * See https://developer.vonage.com/en/video/guides/video-api-basics-overview#basic-vonage-video-api-functionality + * @param {string} roomName - the name of the meeting room + * @returns {Credential} the credentials needed to enter the meeting room + */ + +export default async (roomName: string) => { + return axios.get(`${API_URL}/session/${roomName}`); +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/reportFeedback.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/reportFeedback.ts new file mode 100644 index 00000000..f93f55f6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/api/reportFeedback.ts @@ -0,0 +1,28 @@ +import axios from 'axios'; +import { API_URL } from '../utils/constants'; + +export type SubmissionData = { + title: string; + name: string; + issue: string; + attachment: string; +}; + +type ResponseType = { + message: string; + ticketUrl: string; +}; + +/** + * Posts a request to the server with feedback from the user. + * @param {SubmissionData} submissionData - The feedback information from the user. + * @returns {Promise>} The response from the server. + */ + +const reportIssue = async (submissionData: SubmissionData) => { + return axios.post<{ + feedbackData: ResponseType; + }>(`${API_URL}/feedback/report`, submissionData); +}; + +export default reportIssue; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettings/Dialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettings/Dialog/index.tsx new file mode 100644 index 00000000..3d759331 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettings/Dialog/index.tsx @@ -0,0 +1 @@ +export { AdvancedSettingsDialog as default } from '../../AdvancedSettingsDialog'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.spec.tsx new file mode 100644 index 00000000..bad02f1b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.spec.tsx @@ -0,0 +1,74 @@ +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import AdvancedSettingsDialog from './AdvancedSettingsDialog'; + +describe('AdvancedSettingsDialog', () => { + beforeEach(() => { + advancedSettings$.setState((state) => ({ ...state, isOpen: true })); + }); + + afterEach(() => { + advancedSettings$.reset(); + }); + + it('renders dialog when open', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + expect(screen.getByText(/^settings$/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /general/i })).toBeInTheDocument(); + }); + + it('switches to the video tab', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /video/i })); + + expect(screen.getByLabelText(/bitrate/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/codec/i)).toBeInTheDocument(); + }); + + it('switches to the audio tab', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /audio/i })); + + expect(screen.getByRole('heading', { name: /^audio$/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/audio bitrate/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/enable opus dtx/i)).toBeInTheDocument(); + }); + + it('switches to the statistics tab', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /statistics/i })); + + expect(screen.getByRole('heading', { name: /^statistics$/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/enable publisher bandwidth estimate/i)).toBeInTheDocument(); + expect(screen.getAllByText(/publisher/i).length).toBeGreaterThan(0); + }); + + it('closes the dialog through context on close', async () => { + render(); + + await userEvent.click(screen.getByLabelText(/close/i)); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); +}); + +function render(ui: ReactElement) { + return renderBase({ui}); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.tsx new file mode 100644 index 00000000..025097b3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/AdvancedSettingsDialog.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Dialog from '@mui/material/Dialog'; +import classNames from 'classnames'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import VividIcon from '@ui/VividIcon'; +import { AdvancedSettingsAudioTab } from './components/AdvancedSettingsAudioTab'; +import { AdvancedSettingsGeneralTab } from './components/AdvancedSettingsGeneralTab'; +import { AdvancedSettingsSidebar } from './components/AdvancedSettingsSidebar'; +import { AdvancedSettingsStatisticsTab } from './components/AdvancedSettingsStatisticsTab'; +import { AdvancedSettingsVideoTab } from './components/AdvancedSettingsVideoTab'; + +const AdvancedSettingsDialog = (): ReactElement => { + const { t } = useTranslation(); + const isOpen = advancedSettings$.use.select(({ isOpen }) => isOpen); + const selectedTab = advancedSettings$.use.select(({ selectedTab }) => selectedTab); + + const tabContent = (() => { + if (selectedTab === 'general') return ; + if (selectedTab === 'video') return ; + if (selectedTab === 'audio') return ; + return ; + })(); + + return ( + +
        +
        +

        + {t('advancedSettings.title')} +

        + +
        + +
        + + +
        {tabContent}
        +
        +
        +
        + ); +}; + +export default AdvancedSettingsDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.spec.tsx new file mode 100644 index 00000000..bcf09aa6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.spec.tsx @@ -0,0 +1,69 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import type { advancedSettings } from '@Context/AdvancedSettings'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import AdvancedSettingsAudioTab from './AdvancedSettingsAudioTab'; + +describe('AdvancedSettingsAudioTab', () => { + afterEach(() => { + advancedSettings$.reset(); + }); + + it('renders all audio controls with automatic bitrate selected by default', () => { + render(); + + expect(screen.getByRole('heading', { name: /^audio$/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/audio bitrate/i)).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('automatic'); + expect( + screen.queryByTestId('advanced-settings-custom-audio-bitrate-slider') + ).not.toBeInTheDocument(); + expect(screen.getByLabelText(/enable opus dtx/i)).toBeChecked(); + expect(screen.getByLabelText(/publisher audio fallback/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/subscriber audio fallback/i)).toBeInTheDocument(); + }); + + it('renders the custom audio bitrate slider when custom mode is selected', () => { + render(, { dialogState: { audioBitrateMode: 'custom' } }); + + expect(screen.getByTestId('advanced-settings-custom-audio-bitrate-slider')).toBeInTheDocument(); + expect(screen.getByText(/6 kbps/i)).toBeInTheDocument(); + expect(screen.getByText(/510 kbps/i)).toBeInTheDocument(); + expect(screen.getByText(/128 kbps/i)).toBeInTheDocument(); + }); + + it('shows custom slider after selecting custom bitrate mode', async () => { + const user = userEvent.setup(); + render(); + + await user.selectOptions(screen.getByRole('combobox'), 'custom'); + + expect(screen.getByTestId('advanced-settings-custom-audio-bitrate-slider')).toBeInTheDocument(); + }); + + it('toggles enable opus dtx checkbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('checkbox', { name: /enable opus dtx/i })); + + expect(screen.getByRole('checkbox', { name: /enable opus dtx/i })).not.toBeChecked(); + }); +}); +type RenderOptions = { + dialogState?: Partial; + initialPath?: string; +}; +function render( + ui: ReactElement, + { dialogState, initialPath = '/waiting-room' }: RenderOptions = {} +) { + if (dialogState) { + advancedSettings$.setState((state) => ({ ...state, ...dialogState })); + } + + return renderBase({ui}); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.tsx new file mode 100644 index 00000000..7719c693 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/AdvancedSettingsAudioTab.tsx @@ -0,0 +1,127 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import SelectField from '@ui/SelectField'; +import SwitchField from '@ui/SwitchField'; +import { ADVANCED_SETTINGS_AUDIO_BITRATE_MODE } from '../../types/types'; + +const { + setAudioBitrateMode, + setCustomAudioBitrate, + setEnableDtx, + setPublisherAudioFallbackEnabled, + setSubscriberAudioFallbackEnabled, +} = advancedSettings$.actions; + +const AdvancedSettingsAudioTab = (): ReactElement => { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const isInWaitingRoom = pathname.startsWith('/waiting-room'); + const nextCallWarningKey = isInWaitingRoom + ? 'advancedSettings.audio.nextCallWarningWaitingRoom' + : 'advancedSettings.audio.nextCallWarningMeetingRoom'; + const audioBitrateMode = advancedSettings$.use.select(({ audioBitrateMode }) => audioBitrateMode); + const customAudioBitrate = advancedSettings$.use.select( + ({ customAudioBitrate }) => customAudioBitrate + ); + const enableDtx = advancedSettings$.use.select(({ enableDtx }) => enableDtx); + const publisherAudioFallbackEnabled = advancedSettings$.use.select( + ({ publisherAudioFallbackEnabled }) => publisherAudioFallbackEnabled + ); + const subscriberAudioFallbackEnabled = advancedSettings$.use.select( + ({ subscriberAudioFallbackEnabled }) => subscriberAudioFallbackEnabled + ); + + const audioBitrateOptions = [ + { + value: ADVANCED_SETTINGS_AUDIO_BITRATE_MODE.automatic, + label: t('advancedSettings.audio.bitrate.options.automatic'), + }, + { + value: ADVANCED_SETTINGS_AUDIO_BITRATE_MODE.custom, + label: t('advancedSettings.audio.bitrate.options.custom'), + }, + ]; + + return ( +
        +

        + {t('advancedSettings.tabs.audio')} +

        + +

        + {t(nextCallWarningKey)} +

        + +
        + + + {audioBitrateMode === ADVANCED_SETTINGS_AUDIO_BITRATE_MODE.custom && ( +
        +

        + {t('advancedSettings.audio.bitrate.customLabel')} +

        + +
        + { + setCustomAudioBitrate(Number(event.target.value)); + }} + data-testid="advanced-settings-custom-audio-bitrate-slider" + aria-label={t('advancedSettings.audio.bitrate.customLabel')} + /> +
        + {t('advancedSettings.audio.bitrate.minimum')} + + {t('advancedSettings.audio.bitrate.currentValue', { + value: customAudioBitrate, + })} + + {t('advancedSettings.audio.bitrate.maximum')} +
        +
        +
        + )} +
        + + + + + + +
        + ); +}; + +export default AdvancedSettingsAudioTab; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/index.tsx new file mode 100644 index 00000000..ce91d797 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsAudioTab/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsAudioTab } from './AdvancedSettingsAudioTab'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.spec.tsx new file mode 100644 index 00000000..6302e208 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.spec.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import AdvancedSettingsCodecPriorityField from './AdvancedSettingsCodecPriorityField'; + +describe('AdvancedSettingsCodecPriorityField', () => { + it('reorders codecs through drag and drop', () => { + const setCodecPriority = vi.fn(); + + render( + + ); + + const vp9Item = screen.getByTestId('advanced-settings-codec-priority-item-vp9'); + const h264Item = screen.getByTestId('advanced-settings-codec-priority-item-h264'); + + fireEvent.dragStart(vp9Item); + fireEvent.dragOver(h264Item); + fireEvent.drop(h264Item); + + expect(setCodecPriority).toHaveBeenCalledWith(['vp8', 'h264', 'vp9']); + }); + + it('renders the codec labels in SDK order by default', () => { + render( + + ); + + const codecItems = within(screen.getByTestId('advanced-settings-codec-priority-list')) + .getAllByRole('listitem') + .map((item) => item.textContent); + + expect(codecItems).toEqual(expect.arrayContaining(['1VP9', '2VP8', '3H.264'])); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.tsx new file mode 100644 index 00000000..3b221c3a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/AdvancedSettingsCodecPriorityField.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import type { DragEvent, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import type { + AdvancedSettingsManualCodecOrder, + AdvancedSettingsVideoCodec, +} from '../../types/types'; + +type AdvancedSettingsCodecPriorityFieldProps = { + codecPriority: AdvancedSettingsManualCodecOrder; + setCodecPriority: (value: AdvancedSettingsManualCodecOrder) => void; +}; + +const AdvancedSettingsCodecPriorityField = ({ + codecPriority, + setCodecPriority, +}: AdvancedSettingsCodecPriorityFieldProps): ReactElement => { + const { t } = useTranslation(); + const [draggedCodec, setDraggedCodec] = useState(null); + const [dropTargetCodec, setDropTargetCodec] = useState(null); + + const handleDragStart = (event: DragEvent, codec: AdvancedSettingsVideoCodec) => { + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', codec); + } + + setDraggedCodec(codec); + setDropTargetCodec(codec); + }; + + const handleDragEnd = () => { + setDraggedCodec(null); + setDropTargetCodec(null); + }; + + const handleDragOver = ( + event: DragEvent, + targetCodec: AdvancedSettingsVideoCodec + ) => { + event.preventDefault(); + + if (!draggedCodec || draggedCodec === targetCodec) return; + + setDropTargetCodec(targetCodec); + }; + + const handleDrop = (event: DragEvent, targetCodec: AdvancedSettingsVideoCodec) => { + event.preventDefault(); + + const draggedCodecFromEvent = event.dataTransfer?.getData('text/plain') as + | AdvancedSettingsVideoCodec + | undefined; + const activeDraggedCodec = draggedCodec ?? draggedCodecFromEvent; + + if (!activeDraggedCodec || activeDraggedCodec === targetCodec) { + handleDragEnd(); + return; + } + + const reorderedCodecs = reorderCodecPriority({ + codecPriority, + draggedCodec: activeDraggedCodec, + targetCodec, + }); + + setCodecPriority(reorderedCodecs); + handleDragEnd(); + }; + + return ( +
        +

        + {t('advancedSettings.video.codec.priority.label')} +

        + +

        + {t('advancedSettings.video.codec.priority.description')} +

        + +
          + {codecPriority.map((codec, index) => { + const isDraggedCodec = draggedCodec === codec; + const isDropTarget = dropTargetCodec === codec && draggedCodec !== codec; + + return ( +
        1. { + handleDragStart(event, codec); + }} + onDragEnd={handleDragEnd} + onDragOver={(event) => { + handleDragOver(event, codec); + }} + onDrop={(event) => { + handleDrop(event, codec); + }} + className={classNames( + 'flex cursor-grab items-center gap-3 rounded-vera-medium border bg-vera-surface px-4 py-3', + isDraggedCodec ? 'border-vera-primary opacity-60' : 'border-vera-border', + isDropTarget ? 'border-vera-primary' : null + )} + data-testid={`advanced-settings-codec-priority-item-${codec}`} + > + + {index + 1} + + + + {t(`advancedSettings.video.codec.priority.options.${codec}`)} + +
        2. + ); + })} +
        +
        + ); +}; + +function reorderCodecPriority(args: { + codecPriority: AdvancedSettingsManualCodecOrder; + draggedCodec: AdvancedSettingsVideoCodec; + targetCodec: AdvancedSettingsVideoCodec; +}): AdvancedSettingsManualCodecOrder { + const { codecPriority, draggedCodec, targetCodec } = args; + const reorderedCodecs = [...codecPriority]; + const draggedCodecIndex = reorderedCodecs.indexOf(draggedCodec); + const targetCodecIndex = reorderedCodecs.indexOf(targetCodec); + + reorderedCodecs.splice(draggedCodecIndex, 1); + reorderedCodecs.splice(targetCodecIndex, 0, draggedCodec); + + return reorderedCodecs as AdvancedSettingsManualCodecOrder; +} + +export default AdvancedSettingsCodecPriorityField; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/index.tsx new file mode 100644 index 00000000..488e84d7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCodecPriorityField/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsCodecPriorityField } from './AdvancedSettingsCodecPriorityField'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.spec.tsx new file mode 100644 index 00000000..7034aa8f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.spec.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render as renderBase, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { advancedSettings } from '@Context/AdvancedSettings'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import AdvancedSettingsCustomVideoBitrateField from './AdvancedSettingsCustomVideoBitrateField'; + +describe('AdvancedSettingsCustomVideoBitrateField', () => { + afterEach(() => { + advancedSettings$.reset(); + }); + + it('renders the current bitrate and range labels', () => { + render(); + + expect(screen.getByText(/custom bitrate/i)).toBeInTheDocument(); + expect(screen.getByTestId('advanced-settings-custom-video-bitrate-slider')).toHaveAttribute( + 'type', + 'range' + ); + expect(screen.getAllByText(/5 kbps/i).length).toBeGreaterThan(0); + expect(screen.getByText(/^10 Mbps$/i)).toBeInTheDocument(); + expect(screen.getByText(/^500 kbps$/i)).toBeInTheDocument(); + }); + + it('calls onChange with the clamped value when slider exceeds the maximum', () => { + const handleChange = vi.fn(); + + render(, { + dialogState: { customVideoBitrate: 9_995_000 }, + }); + + const slider = screen.getByTestId('advanced-settings-custom-video-bitrate-slider'); + + fireEvent.change(slider, { target: { value: '20000000' } }); + + expect(handleChange).toHaveBeenCalledWith(10_000_000); + }); +}); + +type RenderOptions = { + dialogState?: Partial; +}; + +function render(ui: ReactElement, { dialogState }: RenderOptions = {}) { + if (dialogState) { + advancedSettings$.setState((state) => ({ ...state, ...dialogState })); + } + + return renderBase(ui); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.tsx new file mode 100644 index 00000000..ccdabcf1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/AdvancedSettingsCustomVideoBitrateField.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { env } from '../../../../env'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import type { AdvancedSettingsCustomVideoBitrate } from '../../types/types'; + +const CUSTOM_VIDEO_BITRATE_STEP_BPS = 5_000; + +type Props = { + onChange: (value: AdvancedSettingsCustomVideoBitrate) => void; +}; + +const AdvancedSettingsCustomVideoBitrateField = ({ onChange }: Props): ReactElement => { + const { t } = useTranslation(); + const customVideoBitrate = advancedSettings$.use.select((state) => state.customVideoBitrate); + const currentCustomVideoBitrate = Number(customVideoBitrate); + + return ( +
        +

        + {t('advancedSettings.video.customBitrate.label')} +

        + +

        + {t('advancedSettings.video.customBitrate.description')} +

        + +
        + { + onChange(clampCustomVideoBitrate(Number(event.target.value))); + }} + className="w-full accent-vera-primary" + data-testid="advanced-settings-custom-video-bitrate-slider" + aria-label={t('advancedSettings.video.customBitrate.label')} + /> + +
        + {t('advancedSettings.video.customBitrate.minimum')} + + {formatVideoBitrateLabel({ + customVideoBitrate: currentCustomVideoBitrate, + lowerUnitLabel: t('advancedSettings.video.customBitrate.units.lower'), + higherUnitLabel: t('advancedSettings.video.customBitrate.units.higher'), + })} + + {t('advancedSettings.video.customBitrate.maximum')} +
        +
        +
        + ); +}; + +function clampCustomVideoBitrate(customVideoBitrate: number): number { + if (customVideoBitrate < env.MIN_CUSTOM_VIDEO_BITRATE_BPS) { + return Number(env.MIN_CUSTOM_VIDEO_BITRATE_BPS); + } + + if (customVideoBitrate > env.MAX_CUSTOM_VIDEO_BITRATE_BPS) { + return Number(env.MAX_CUSTOM_VIDEO_BITRATE_BPS); + } + + return customVideoBitrate; +} + +function formatVideoBitrateLabel({ + customVideoBitrate, + lowerUnitLabel, + higherUnitLabel, +}: { + customVideoBitrate: number; + lowerUnitLabel: string; + higherUnitLabel: string; +}): string { + if (customVideoBitrate >= 1_000_000) { + return `${customVideoBitrate / 1_000_000} ${higherUnitLabel}`; + } + + return `${Math.round(customVideoBitrate / 1_000)} ${lowerUnitLabel}`; +} + +export default AdvancedSettingsCustomVideoBitrateField; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/index.tsx new file mode 100644 index 00000000..d81b6d94 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsCustomVideoBitrateField/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsCustomVideoBitrateField } from './AdvancedSettingsCustomVideoBitrateField'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.spec.tsx new file mode 100644 index 00000000..e2264000 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.spec.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import AdvancedSettingsGeneralTab from './AdvancedSettingsGeneralTab'; + +describe('AdvancedSettingsGeneralTab', () => { + it('renders the general tab content', () => { + render(); + + expect(screen.getByRole('heading', { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reset default values/i })).toBeInTheDocument(); + expect(screen.getByText(/restore all settings to their default values/i)).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.tsx new file mode 100644 index 00000000..32a85788 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/AdvancedSettingsGeneralTab.tsx @@ -0,0 +1,27 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +const AdvancedSettingsGeneralTab = (): ReactElement => { + const { t } = useTranslation(); + + return ( +
        +

        + {t('advancedSettings.tabs.general')} +

        +
        + +

        + {t('advancedSettings.general.resetDescription')} +

        +
        +
        + ); +}; + +export default AdvancedSettingsGeneralTab; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/index.tsx new file mode 100644 index 00000000..fce95ded --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsGeneralTab/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsGeneralTab } from './AdvancedSettingsGeneralTab'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.spec.tsx new file mode 100644 index 00000000..3fa311b9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.spec.tsx @@ -0,0 +1,29 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import AdvancedSettingsSidebar from './AdvancedSettingsSidebar'; + +describe('AdvancedSettingsSidebar', () => { + it('renders all tabs', () => { + render(); + + expect(screen.getByRole('button', { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /audio/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /statistics/i })).toBeInTheDocument(); + }); + + it('updates selected tab when clicking another tab', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /statistics/i })); + + expect(screen.getByRole('button', { name: /statistics/i })).toHaveClass('bg-vera-surface'); + }); +}); + +function render(ui: ReactElement) { + return renderBase(ui); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.tsx new file mode 100644 index 00000000..3d38f6aa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/AdvancedSettingsSidebar.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import type { AdvancedSettingsTab } from '../../types/types'; + +const tabs: AdvancedSettingsTab[] = ['general', 'video', 'audio', 'statistics']; +const { setSelectedTab } = advancedSettings$.actions; + +const AdvancedSettingsSidebar = (): ReactElement => { + const { t } = useTranslation(); + const selectedTab = advancedSettings$.use.select((state) => state.selectedTab); + + return ( +
        + {tabs.map((tab) => { + const isSelected = selectedTab === tab; + + return ( + + ); + })} +
        + ); +}; + +export default AdvancedSettingsSidebar; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/index.tsx new file mode 100644 index 00000000..d3f7c8b9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsSidebar/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsSidebar } from './AdvancedSettingsSidebar'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.spec.tsx new file mode 100644 index 00000000..cd1a5bad --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import AdvancedSettingsStatisticsGroup from './AdvancedSettingsStatisticsGroup'; + +describe('AdvancedSettingsStatisticsGroup', () => { + it('renders only the statistics sections that have items', () => { + render( + + ); + + expect(screen.getAllByText(/publisher/i).length).toBeGreaterThan(0); + expect(screen.queryByRole('heading', { name: /audio/i })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /video/i })).toBeInTheDocument(); + expect(screen.getByText(/bytes received/i)).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.tsx new file mode 100644 index 00000000..0cf5ac3a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/AdvancedSettingsStatisticsGroup.tsx @@ -0,0 +1,89 @@ +import { useMemo, type ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Collapsible } from '@ui/components'; +import LabeledValueList from '@ui/LabeledValueList'; +import type { IMetricValue } from '@core/metrics'; +import type { Any } from '@common/types'; + +type AdvancedSettingsStatisticItem = { + label: string; + value: IMetricValue | string; +}; + +type AdvancedSettingsStatisticsGroupProps = { + title: string; + audioItems: AdvancedSettingsStatisticItem[]; + videoItems: AdvancedSettingsStatisticItem[]; + defaultExpanded?: boolean; +}; + +const AdvancedSettingsStatisticsGroup = ({ + title, + audioItems, + videoItems, + defaultExpanded = false, +}: AdvancedSettingsStatisticsGroupProps): ReactElement => { + const { t } = useTranslation(); + const hasStatistics = audioItems.length > 0 || videoItems.length > 0; + + const formattedAudioItems = useMemo( + () => + audioItems.map(({ label, value }) => ({ + label, + value: value.toString(), + })), + [audioItems] + ); + + const formattedVideoItems = useMemo( + () => + videoItems.map(({ label, value }) => ({ + label, + value: value.toString(), + })), + [videoItems] + ); + + return ( + + + + {title} + + + + + + + {hasStatistics && ( +
        + {audioItems.length > 0 && ( + + )} + + {videoItems.length > 0 && ( + + )} +
        + )} + + {!hasStatistics && ( +

        + {t('advancedSettings.statistics.empty')} +

        + )} +
        +
        + ); +}; + +export default AdvancedSettingsStatisticsGroup; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/index.tsx new file mode 100644 index 00000000..913d618f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsGroup/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsStatisticsGroup } from './AdvancedSettingsStatisticsGroup'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.spec.tsx new file mode 100644 index 00000000..8736f1c1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.spec.tsx @@ -0,0 +1,161 @@ +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Publisher, Subscriber } from '@vonage/client-sdk-video'; +import type { SubscriberWrapper } from '@app-types/session'; +import AdvancedSettingsStatisticsTab from './AdvancedSettingsStatisticsTab'; +import makeTestProvider, { ProviderOptions, providers } from '@test/providers/makeTestProvider'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; + +describe('AdvancedSettingsStatisticsTab', () => { + vi.mock('@hooks/usePermissions', () => ({ + default: () => ({ + accessStatus: DEVICE_ACCESS_STATUS.ACCEPTED, + setAccessStatus: vi.fn(), + }), + })); + + it('renders collection and an empty publisher statistics group', () => { + render(); + + expect(screen.getByRole('heading', { name: /^statistics$/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/enable publisher bandwidth estimate/i)).toBeInTheDocument(); + expect(screen.getAllByText(/publisher/i).length).toBeGreaterThan(0); + expect(screen.getByText(/statistics/i)).toBeInTheDocument(); + }); + + it('renders live publisher stats when statistics are enabled', async () => { + const publisher = { + videoWidth: vi.fn(() => 1280), + videoHeight: vi.fn(() => 720), + getStats: vi.fn((callback) => { + callback(undefined, [ + { + stats: { + audio: { packetsSent: 10, packetsLost: 0, bytesSent: 1024 }, + video: { + packetsSent: 50, + packetsLost: 1, + bytesSent: 51200, + frameRate: 30, + bitrate: 3_000_000, + connectionEstimatedBandwidthBps: 3_000_000, + layers: [], + }, + mediaLink: { transport: { connectionEstimatedBandwidth: 3_000_000 } }, + }, + }, + ]); + }), + } as unknown as Publisher; + + render(, { + publisherContext: { + __interceptor: (context) => { + if (context) { + context.publisher = publisher; + } + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('30 fps')).toBeInTheDocument(); + expect(screen.getByText('1280x720')).toBeInTheDocument(); + // expect(screen.getByText('3.00 Mbps')).toBeInTheDocument(); + }); + }); + + it('renders subscriber group with codec, decoded frame rate and freeze count', async () => { + const subscriberWrapper: SubscriberWrapper = { + id: 'sub-1', + element: document.createElement('video'), + isScreenshare: false, + isPinned: false, + subscriber: { + stream: { name: 'Bob' }, + getStats: vi.fn((callback) => { + callback(undefined, { + audio: { packetsReceived: 10, packetsLost: 0, bytesReceived: 500 }, + video: { + packetsReceived: 40, + packetsLost: 0, + bytesReceived: 20000, + width: 640, + height: 480, + codec: 'VP9', + frameRate: 24, + decodedFrameRate: 23, + bitrate: 600_000, + freezeCount: 5, + totalFreezesDuration: 1200, + }, + mediaLink: { + transport: { connectionEstimatedBandwidth: 1_000_000 }, + remotePublisherTransport: { connectionEstimatedBandwidth: 900_000 }, + }, + }); + }), + } as unknown as Subscriber, + }; + + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = [subscriberWrapper]; + } + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('VP9')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + }); +}); + +type RenderOptions = { + advancedSettingsContext?: ProviderOptions['AdvancedSettingsContext']; + userContext?: ProviderOptions['UserContext']; + publisherContext?: ProviderOptions['PublisherContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render( + ui: ReactElement, + { + advancedSettingsContext, + userContext, + publisherContext, + previewPublisherContext, + sessionContext, + }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.advancedSettings, + providers.runtime, + providers.user, + providers.publisher, + providers.previewPublisher, + providers.session, + ], + { + advancedSettingsContext, + runtimeContext: undefined, + userContext, + sessionContext, + publisherContext, + previewPublisherContext, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.tsx new file mode 100644 index 00000000..17cb5ad7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/AdvancedSettingsStatisticsTab.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import usePublisherContext from '@hooks/usePublisherContext'; +import SwitchField from '@ui/SwitchField'; +import PublisherStatistics from './components/PublisherStatistics'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import SubscriberStatistics from './components/SubscriberStatistics'; +import useSessionContext from '@hooks/useSessionContext'; + +const { setPublisherStatisticsEnabled } = advancedSettings$.actions; + +const AdvancedSettingsStatisticsTab = (): ReactElement => { + const { t } = useTranslation(); + const { publisher: meetingPublisher } = usePublisherContext(); + const { publisher: previewPublisher } = usePreviewPublisherContext(); + const { subscriberWrappers } = useSessionContext(); + + const publisher = meetingPublisher ?? previewPublisher; + + const publisherStatisticsEnabled = advancedSettings$.use.select( + (state) => state.publisherStatisticsEnabled + ); + + return ( +
        +

        + {t('advancedSettings.tabs.statistics')} +

        + + +
        + {publisher && } + + {subscriberWrappers.length > 0 && ( +

        + {t('advancedSettings.statistics.groups.subscribers')} +

        + )} + + {subscriberWrappers.map(({ subscriber }, index) => ( + + ))} +
        +
        + ); +}; + +export default AdvancedSettingsStatisticsTab; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/PublisherStatistics.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/PublisherStatistics.tsx new file mode 100644 index 00000000..382f105b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/PublisherStatistics.tsx @@ -0,0 +1,117 @@ +import { type ReactElement, useMemo } from 'react'; +import { usePublisherStats } from '@core/hooks'; +import { useTranslation } from 'react-i18next'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import { AdvancedSettingsStatisticsGroup } from '../../AdvancedSettingsStatisticsGroup'; +import { Publisher } from '@vonage/client-sdk-video'; +import { BitrateValue, FrameRateValue, optionalValue, ResolutionValue } from '@core/metrics'; + +interface PublisherStatisticsProps { + publisher: Publisher; +} + +const optionalValueArgs = { fallback: '–' }; + +const PublisherStatistics = ({ publisher }: PublisherStatisticsProps): ReactElement => { + const { t } = useTranslation(); + + const publisherStatisticsEnabled = advancedSettings$.use.select( + (state) => state.publisherStatisticsEnabled + ); + const fixedFrameRate = advancedSettings$.use.select((state) => state.frameRate); + + const { data } = usePublisherStats({ + publisher, + publisherStatisticsEnabled, + fixedFrameRate: fixedFrameRate ?? null, + }); + + const publisherAudioStatistics = useMemo(() => { + if (!data?.audio) { + return []; + } + + return [ + { + label: t('advancedSettings.statistics.metrics.packetsSent'), + value: data.audio.packetsSent, + }, + { + label: t('advancedSettings.statistics.metrics.packetsLostSent'), + value: data.audio.packetsLost, + }, + { + label: t('advancedSettings.statistics.metrics.bytesSent'), + value: data.audio.bytesSent, + }, + ]; + }, [data, t]); + + const publisherVideoStatistics = useMemo(() => { + if (!data?.video) { + return []; + } + + return [ + { + label: t('advancedSettings.statistics.metrics.resolution'), + value: data.resolution, + }, + { + label: t('advancedSettings.statistics.metrics.frameRate'), + value: data.frameRate, + }, + { + label: t('advancedSettings.statistics.metrics.bitrate'), + value: data.bitrateBps, + }, + { + label: t('advancedSettings.statistics.metrics.packetLoss'), + value: data.packetLossRatio, + }, + { + label: t('advancedSettings.statistics.metrics.packetsSent'), + value: data.video.packetsSent, + }, + { + label: t('advancedSettings.statistics.metrics.packetsLostSent'), + value: data.video.packetsLost, + }, + { + label: t('advancedSettings.statistics.metrics.bytesSent'), + value: data.video.bytesSent, + }, + { + label: t('advancedSettings.statistics.metrics.estimatedBandwidth'), + value: data.connectionEstimatedBandwidthBps, + }, + + ...(data.videoLayers ?? []).map((layer, index) => ({ + label: t('advancedSettings.statistics.metrics.videoLayer', { + index: index + 1, + codec: layer.codec, + }), + + value: [ + optionalValue(ResolutionValue, layer, optionalValueArgs), + optionalValue(FrameRateValue, layer.encodedFrameRate, optionalValueArgs), + optionalValue(BitrateValue, layer.bitrate, optionalValueArgs), + layer.qualityLimitationReason !== 'none' ? layer.qualityLimitationReason : null, + ] + .filter(Boolean) + .join(' · '), + })), + ]; + }, [data, t]); + + return ( + + ); +}; + +export default PublisherStatistics; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/SubscriberStatistics.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/SubscriberStatistics.tsx new file mode 100644 index 00000000..211aebbc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/components/SubscriberStatistics.tsx @@ -0,0 +1,110 @@ +import { useMemo, type ReactElement } from 'react'; +import { useSubscriberStats } from '@core/hooks'; +import { useTranslation } from 'react-i18next'; +import { AdvancedSettingsStatisticsGroup } from '../../AdvancedSettingsStatisticsGroup'; +import { Subscriber } from '@vonage/client-sdk-video'; + +interface SubscriberStatisticsProps { + subscriber: Subscriber; +} + +const SubscriberStatistics = ({ subscriber }: SubscriberStatisticsProps): ReactElement => { + const { t } = useTranslation(); + + const { data } = useSubscriberStats({ subscriber }); + + const subscriberStatisticsGroups = useMemo(() => { + if (!data) { + return { + id: 'no-stats', + title: '...', + audioItems: [], + videoItems: [], + }; + } + + return { + id: data.id, + title: data?.title, + audioItems: [ + { + label: t('advancedSettings.statistics.metrics.packetsReceived'), + value: data.audio.packetsReceived, + }, + { + label: t('advancedSettings.statistics.metrics.packetsLostReceived'), + value: data.audio.packetsLost, + }, + { + label: t('advancedSettings.statistics.metrics.bytesReceived'), + value: data.audio.bytesReceived, + }, + ], + videoItems: [ + { + label: t('advancedSettings.statistics.metrics.resolution'), + value: data.video.resolution, + }, + { + label: t('advancedSettings.statistics.metrics.codec'), + value: data.video.codec ?? '–', + }, + { + label: t('advancedSettings.statistics.metrics.frameRate'), + value: data.video.frameRate, + }, + { + label: t('advancedSettings.statistics.metrics.decodedFrameRate'), + value: data.video.decodedFrameRate, + }, + { + label: t('advancedSettings.statistics.metrics.bitrate'), + value: data.video.bitrateBps, + }, + { + label: t('advancedSettings.statistics.metrics.packetLoss'), + value: data.packetLossRatio, + }, + { + label: t('advancedSettings.statistics.metrics.freezeCount'), + value: data.video.freezeCount, + }, + { + label: t('advancedSettings.statistics.metrics.totalFreezesDuration'), + value: data.video.totalFreezesDuration, + }, + { + label: t('advancedSettings.statistics.metrics.packetsReceived'), + value: data.video.packetsReceived, + }, + { + label: t('advancedSettings.statistics.metrics.packetsLostReceived'), + value: data.video.packetsLost, + }, + { + label: t('advancedSettings.statistics.metrics.bytesReceived'), + value: data.video.bytesReceived, + }, + { + label: t('advancedSettings.statistics.metrics.estimatedBandwidth'), + value: data.connectionEstimatedBandwidthBps, + }, + { + label: t('advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth'), + value: data.remotePublisherConnectionEstimatedBandwidthBps, + }, + ], + }; + }, [data, t]); + + return ( + + ); +}; + +export default SubscriberStatistics; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/index.tsx new file mode 100644 index 00000000..86d768f9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsStatisticsTab/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsStatisticsTab } from './AdvancedSettingsStatisticsTab'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.spec.tsx new file mode 100644 index 00000000..2069b2d3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.spec.tsx @@ -0,0 +1,54 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { advancedSettings } from '@Context/AdvancedSettings'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import AdvancedSettingsVideoTab from './AdvancedSettingsVideoTab'; + +type RenderOptions = { + dialogState?: Partial; +}; + +describe('AdvancedSettingsVideoTab', () => { + afterEach(() => { + advancedSettings$.reset(); + }); + + it('renders all video sections', () => { + render(); + + expect(screen.getByRole('heading', { name: /video/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/bitrate/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/codec/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/frame rate/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/resolution/i)).toBeInTheDocument(); + }); + + it('renders codec priority drag and drop when codec mode is manual', () => { + render(, { + dialogState: { codecMode: 'manual', codecPriority: ['vp9', 'vp8', 'h264'] }, + }); + + expect(screen.getByText(/codec priority/i)).toBeInTheDocument(); + expect(screen.getByTestId('advanced-settings-codec-priority-item-vp9')).toBeInTheDocument(); + expect(screen.getByTestId('advanced-settings-codec-priority-item-vp8')).toBeInTheDocument(); + expect(screen.getByTestId('advanced-settings-codec-priority-item-h264')).toBeInTheDocument(); + }); + + it('renders custom video bitrate controls when bitrate mode is custom', () => { + render(, { dialogState: { bitrateMode: 'custom' } }); + + expect(screen.getByText(/custom bitrate/i)).toBeInTheDocument(); + expect(screen.getByTestId('advanced-settings-custom-video-bitrate-slider')).toBeInTheDocument(); + expect(screen.getAllByText(/5 kbps/i).length).toBeGreaterThan(0); + expect(screen.getByText(/^10 Mbps$/i)).toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement, { dialogState }: RenderOptions = {}) { + if (dialogState) { + advancedSettings$.setState((state) => ({ ...state, ...dialogState })); + } + + return renderBase(ui); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.tsx new file mode 100644 index 00000000..bbfab1dc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/AdvancedSettingsVideoTab.tsx @@ -0,0 +1,135 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { env, RESOLUTIONS } from '../../../../env'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import SelectField from '@ui/SelectField'; +import { AdvancedSettingsCodecPriorityField } from '../AdvancedSettingsCodecPriorityField'; +import { AdvancedSettingsCustomVideoBitrateField } from '../AdvancedSettingsCustomVideoBitrateField'; +import type { + AdvancedSettingsFrameRate, + AdvancedSettingsResolution, + AdvancedSettingsSelectOption, +} from '../../types/types'; +import { ADVANCED_SETTINGS_BITRATE_MODE, ADVANCED_SETTINGS_CODEC_MODE } from '../../types/types'; +import useAdvancedSettingsVideoHandlers from './useAdvancedSettingsVideoHandlers'; + +const { setCodecMode, setCodecPriority } = advancedSettings$.actions; + +const AdvancedSettingsVideoTab = (): ReactElement => { + const { t } = useTranslation(); + const bitrateMode = advancedSettings$.use.select(({ bitrateMode }) => bitrateMode); + const codecMode = advancedSettings$.use.select(({ codecMode }) => codecMode); + const codecPriority = advancedSettings$.use.select(({ codecPriority }) => codecPriority); + const frameRate = advancedSettings$.use.select(({ frameRate }) => frameRate); + const resolution = advancedSettings$.use.select(({ resolution }) => resolution); + const customVideoBitrate = advancedSettings$.use.select( + ({ customVideoBitrate }) => customVideoBitrate + ); + + const { + handleFrameRateChange, + handleResolutionChange, + handleBitrateModeChange, + handleCustomVideoBitrateChange, + } = useAdvancedSettingsVideoHandlers({ bitrateMode, customVideoBitrate }); + + const bitrateOptions = [ + { + value: ADVANCED_SETTINGS_BITRATE_MODE.default, + label: t('advancedSettings.video.bitrate.options.default'), + }, + { + value: ADVANCED_SETTINGS_BITRATE_MODE.bwSaver, + label: t('advancedSettings.video.bitrate.options.bw_saver'), + }, + { + value: ADVANCED_SETTINGS_BITRATE_MODE.extraBwSaver, + label: t('advancedSettings.video.bitrate.options.extra_bw_saver'), + }, + { + value: ADVANCED_SETTINGS_BITRATE_MODE.custom, + label: t('advancedSettings.video.bitrate.options.custom'), + }, + ]; + + const codecOptions = [ + { + value: ADVANCED_SETTINGS_CODEC_MODE.automatic, + label: t('advancedSettings.video.codec.options.automatic'), + }, + { + value: ADVANCED_SETTINGS_CODEC_MODE.manual, + label: t('advancedSettings.video.codec.options.manual'), + }, + ]; + + const frameRateOptions: AdvancedSettingsSelectOption[] = ( + env.SUPPORTED_FRAME_RATES as AdvancedSettingsFrameRate[] + ).map((supportedFrameRate) => ({ + value: supportedFrameRate, + label: t(`advancedSettings.video.frameRate.options.${supportedFrameRate}`), + })); + + const resolutionOptions: AdvancedSettingsSelectOption[] = + RESOLUTIONS.map((supportedResolution) => ({ + value: supportedResolution, + label: supportedResolution, + })); + + return ( +
        +

        + {t('advancedSettings.tabs.video')} +

        + +
        + + + {bitrateMode === ADVANCED_SETTINGS_BITRATE_MODE.custom && ( + + )} + + + + {codecMode === ADVANCED_SETTINGS_CODEC_MODE.manual && ( + + )} + + + + +
        +
        + ); +}; + +export default AdvancedSettingsVideoTab; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/index.tsx new file mode 100644 index 00000000..5c01c3b9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsVideoTab } from './AdvancedSettingsVideoTab'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.spec.ts new file mode 100644 index 00000000..d06fdb14 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.spec.ts @@ -0,0 +1,283 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; +import type { Publisher } from '@vonage/client-sdk-video'; +import type { PublisherContextType } from '@Context/PublisherProvider'; +import type { PreviewPublisherContextType } from '@Context/PreviewPublisherProvider'; +import usePublisherContext from '@hooks/usePublisherContext'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import { handleClientApplicationError } from '@ui/helpers'; +import useAdvancedSettingsVideoHandlers from './useAdvancedSettingsVideoHandlers'; + +vi.mock('@hooks/usePublisherContext'); +vi.mock('@hooks/usePreviewPublisherContext'); +vi.mock('@ui/helpers', () => ({ + handleClientApplicationError: vi.fn(), +})); + +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; +const mockUsePreviewPublisherContext = usePreviewPublisherContext as Mock< + [], + PreviewPublisherContextType +>; +const mockHandleClientApplicationError = vi.mocked(handleClientApplicationError); + +const createMockPublisher = () => + ({ + getVideoSource: vi.fn().mockReturnValue({ track: {} }), + setPreferredFrameRate: vi.fn().mockResolvedValue(undefined), + setPreferredResolution: vi.fn().mockResolvedValue(undefined), + setMaxVideoBitrate: vi.fn().mockResolvedValue(undefined), + setVideoBitratePreset: vi.fn().mockResolvedValue(undefined), + }) as unknown as Publisher; + +describe('useAdvancedSettingsVideoHandlers', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockUsePublisherContext.mockReturnValue({ publisher: null } as PublisherContextType); + mockUsePreviewPublisherContext.mockReturnValue({ + publisher: null, + } as unknown as PreviewPublisherContextType); + }); + + afterEach(() => { + advancedSettings$.reset(); + }); + + describe('handleFrameRateChange', () => { + it('applies frame rate to publisher then updates store', async () => { + const publisher = createMockPublisher(); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleFrameRateChange(15); + }); + + await waitFor(() => { + expect(publisher.setPreferredFrameRate).toHaveBeenCalledWith(15); + expect(advancedSettings$.getState().frameRate).toBe(15); + }); + }); + + it('still updates store when no publisher is active', async () => { + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleFrameRateChange(7); + }); + + await waitFor(() => { + expect(advancedSettings$.getState().frameRate).toBe(7); + }); + }); + + it('does not update store when publisher call fails', async () => { + const publisher = createMockPublisher(); + (publisher.setPreferredFrameRate as Mock).mockRejectedValue(new Error('hardware error')); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const initialFrameRate = advancedSettings$.getState().frameRate; + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleFrameRateChange(15); + }); + + await waitFor(() => { + expect(publisher.setPreferredFrameRate).toHaveBeenCalledWith(15); + }); + + expect(advancedSettings$.getState().frameRate).toBe(initialFrameRate); + }); + + it('reports a notification after a failed frame rate update', async () => { + const publisher = createMockPublisher(); + (publisher.setPreferredFrameRate as Mock).mockRejectedValue(new Error('hardware error')); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleFrameRateChange(15); + }); + + await waitFor(() => { + expect(mockHandleClientApplicationError).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('handleResolutionChange', () => { + it('applies resolution to publisher then updates store', async () => { + const publisher = createMockPublisher(); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleResolutionChange('640x480'); + }); + + await waitFor(() => { + expect(publisher.setPreferredResolution).toHaveBeenCalledWith({ width: 640, height: 480 }); + expect(advancedSettings$.getState().resolution).toBe('640x480'); + }); + }); + + it('does not update store when resolution update fails', async () => { + const publisher = createMockPublisher(); + (publisher.setPreferredResolution as Mock).mockRejectedValue(new Error('unsupported')); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + const initialResolution = advancedSettings$.getState().resolution; + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleResolutionChange('640x480'); + }); + + await waitFor(() => { + expect(mockHandleClientApplicationError).toHaveBeenCalledTimes(1); + }); + + expect(advancedSettings$.getState().resolution).toBe(initialResolution); + }); + }); + + describe('handleBitrateModeChange', () => { + it('applies bitrate preset to publisher then updates store', async () => { + const publisher = createMockPublisher(); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleBitrateModeChange('bw_saver'); + }); + + await waitFor(() => { + expect(publisher.setVideoBitratePreset).toHaveBeenCalledWith('bw_saver'); + expect(advancedSettings$.getState().bitrateMode).toBe('bw_saver'); + }); + }); + + it('uses preview publisher when no meeting room publisher is active', async () => { + const previewPublisher = createMockPublisher(); + mockUsePreviewPublisherContext.mockReturnValue({ + publisher: previewPublisher, + } as unknown as PreviewPublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleBitrateModeChange('bw_saver'); + }); + + await waitFor(() => { + expect(previewPublisher.setVideoBitratePreset).toHaveBeenCalledWith('bw_saver'); + }); + }); + + it('does not update store when bitrate preset update fails', async () => { + const publisher = createMockPublisher(); + (publisher.setVideoBitratePreset as Mock).mockRejectedValue(new Error('unsupported')); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + const initialBitrateMode = advancedSettings$.getState().bitrateMode; + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleBitrateModeChange('bw_saver'); + }); + + await waitFor(() => { + expect(mockHandleClientApplicationError).toHaveBeenCalledTimes(1); + }); + + expect(advancedSettings$.getState().bitrateMode).toBe(initialBitrateMode); + }); + }); + + describe('handleCustomVideoBitrateChange', () => { + it('applies custom bitrate to publisher then updates store when mode is custom', async () => { + const publisher = createMockPublisher(); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'custom', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleCustomVideoBitrateChange(750_000); + }); + + await waitFor(() => { + expect(publisher.setMaxVideoBitrate).toHaveBeenCalledWith(750_000); + expect(advancedSettings$.getState().customVideoBitrate).toBe(750_000); + }); + }); + + it('updates store without calling the publisher when mode is not custom', async () => { + const publisher = createMockPublisher(); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'default', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleCustomVideoBitrateChange(750_000); + }); + + await waitFor(() => { + expect(advancedSettings$.getState().customVideoBitrate).toBe(750_000); + }); + + expect(publisher.setMaxVideoBitrate).not.toHaveBeenCalled(); + expect(publisher.setVideoBitratePreset).not.toHaveBeenCalled(); + }); + + it('does not update store when custom bitrate update fails', async () => { + const publisher = createMockPublisher(); + (publisher.setMaxVideoBitrate as Mock).mockRejectedValue(new Error('unsupported')); + mockUsePublisherContext.mockReturnValue({ publisher } as PublisherContextType); + const initialCustomVideoBitrate = advancedSettings$.getState().customVideoBitrate; + + const { result } = renderHook(() => + useAdvancedSettingsVideoHandlers({ bitrateMode: 'custom', customVideoBitrate: 500_000 }) + ); + + await act(async () => { + await result.current.handleCustomVideoBitrateChange(750_000); + }); + + await waitFor(() => { + expect(mockHandleClientApplicationError).toHaveBeenCalledTimes(1); + }); + + expect(advancedSettings$.getState().customVideoBitrate).toBe(initialCustomVideoBitrate); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.ts new file mode 100644 index 00000000..a5a966a3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/components/AdvancedSettingsVideoTab/useAdvancedSettingsVideoHandlers.ts @@ -0,0 +1,89 @@ +import usePublisherContext from '@hooks/usePublisherContext'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import { makeApplicationErrorMapper } from '@core/errors'; +import { handleClientApplicationError } from '@ui/helpers'; +import { + applyFrameRate, + applyResolution, + applyBitrate, +} from '@Context/PublisherProvider/useApplyAdvancedSettings'; +import { t } from 'i18next'; +import type { + AdvancedSettingsBitrateMode, + AdvancedSettingsCustomVideoBitrate, + AdvancedSettingsFrameRate, + AdvancedSettingsResolution, +} from '../../types/types'; +import { ADVANCED_SETTINGS_BITRATE_MODE } from '../../types/types'; + +const { setBitrateMode, setCustomVideoBitrate, setFrameRate, setResolution } = + advancedSettings$.actions; + +type UseAdvancedSettingsVideoHandlersArgs = { + bitrateMode: AdvancedSettingsBitrateMode; + customVideoBitrate: AdvancedSettingsCustomVideoBitrate; +}; + +type UseAdvancedSettingsVideoHandlers = { + handleFrameRateChange: (value: AdvancedSettingsFrameRate) => Promise; + handleResolutionChange: (value: AdvancedSettingsResolution) => Promise; + handleBitrateModeChange: (value: AdvancedSettingsBitrateMode) => Promise; + handleCustomVideoBitrateChange: (value: AdvancedSettingsCustomVideoBitrate) => Promise; +}; + +const useAdvancedSettingsVideoHandlers = ({ + bitrateMode, + customVideoBitrate, +}: UseAdvancedSettingsVideoHandlersArgs): UseAdvancedSettingsVideoHandlers => { + const { publisher: meetingRoomPublisher } = usePublisherContext(); + const { publisher: previewPublisher } = usePreviewPublisherContext(); + const publisher = meetingRoomPublisher ?? previewPublisher ?? null; + + const handleFrameRateChange = async (value: AdvancedSettingsFrameRate) => { + try { + await applyFrameRate(publisher, value); + setFrameRate(value); + } catch (error) { + handleClientApplicationError(makeApplicationErrorMapper(t('errors.unknown'))(error)); + } + }; + + const handleResolutionChange = async (value: AdvancedSettingsResolution) => { + try { + await applyResolution(publisher, value); + setResolution(value); + } catch (error) { + handleClientApplicationError(makeApplicationErrorMapper(t('errors.unknown'))(error)); + } + }; + + const handleBitrateModeChange = async (value: AdvancedSettingsBitrateMode) => { + try { + await applyBitrate(publisher, value, customVideoBitrate); + setBitrateMode(value); + } catch (error) { + handleClientApplicationError(makeApplicationErrorMapper(t('errors.unknown'))(error)); + } + }; + + const handleCustomVideoBitrateChange = async (value: AdvancedSettingsCustomVideoBitrate) => { + try { + if (bitrateMode === ADVANCED_SETTINGS_BITRATE_MODE.custom) { + await applyBitrate(publisher, bitrateMode, value); + } + setCustomVideoBitrate(value); + } catch (error) { + handleClientApplicationError(makeApplicationErrorMapper(t('errors.unknown'))(error)); + } + }; + + return { + handleFrameRateChange, + handleResolutionChange, + handleBitrateModeChange, + handleCustomVideoBitrateChange, + }; +}; + +export default useAdvancedSettingsVideoHandlers; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/index.tsx new file mode 100644 index 00000000..c24c46ba --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/index.tsx @@ -0,0 +1 @@ +export { default as AdvancedSettingsDialog } from './AdvancedSettingsDialog'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/types/types.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/types/types.ts new file mode 100644 index 00000000..4859d66b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AdvancedSettingsDialog/types/types.ts @@ -0,0 +1,49 @@ +import type { Resolution } from '../../../env'; + +export type AdvancedSettingsTab = 'general' | 'video' | 'audio' | 'statistics'; + +export type AdvancedSettingsBitrateMode = 'default' | 'bw_saver' | 'extra_bw_saver' | 'custom'; + +export const ADVANCED_SETTINGS_BITRATE_MODE = { + default: 'default', + bwSaver: 'bw_saver', + extraBwSaver: 'extra_bw_saver', + custom: 'custom', +} as const satisfies Record; + +export type AdvancedSettingsCustomVideoBitrate = number; + +export type AdvancedSettingsCodecMode = 'automatic' | 'manual'; + +export const ADVANCED_SETTINGS_CODEC_MODE = { + automatic: 'automatic' as AdvancedSettingsCodecMode, + manual: 'manual' as AdvancedSettingsCodecMode, +}; + +export type AdvancedSettingsVideoCodec = 'vp8' | 'vp9' | 'h264'; + +export type AdvancedSettingsManualCodecOrder = [ + AdvancedSettingsVideoCodec, + AdvancedSettingsVideoCodec, + AdvancedSettingsVideoCodec, +]; + +export type AdvancedSettingsFrameRate = NonNullable< + import('@vonage/client-sdk-video').GetUserMediaProperties['frameRate'] +>; + +export type AdvancedSettingsResolution = Resolution; + +export type AdvancedSettingsAudioBitrateMode = 'automatic' | 'custom'; + +export const ADVANCED_SETTINGS_AUDIO_BITRATE_MODE = { + automatic: 'automatic' as AdvancedSettingsAudioBitrateMode, + custom: 'custom' as AdvancedSettingsAudioBitrateMode, +}; + +export type AdvancedSettingsCustomAudioBitrate = number; + +export type AdvancedSettingsSelectOption = { + value: TValue; + label: string; +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.spec.tsx new file mode 100644 index 00000000..b82ab413 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.spec.tsx @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import AvatarInitials from './AvatarInitials'; + +describe('AvatarInitials', () => { + afterEach(() => { + cleanup(); + }); + + it('should render initials when given', () => { + const initials = 'HI'; + render(); + + expect(screen.getByText(initials)).toBeVisible(); + expect(screen.queryByTestId('PersonIcon')).not.toBeInTheDocument(); + }); + + it('should render person avatar when no initials', () => { + render(); + + expect(screen.getByTestId('PersonIcon')).toBeVisible(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.tsx new file mode 100644 index 00000000..46240185 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/AvatarInitials.tsx @@ -0,0 +1,56 @@ +import { ReactElement } from 'react'; +import Avatar from '@mui/material/Avatar'; +import type { SxProps } from '@mui/material'; +import getParticipantColor from '../../utils/getParticipantColor'; + +export type InitialsProps = { + initials?: string; + username?: string; + sx?: SxProps; + height?: number; + width?: number; + 'data-testid'?: string; +}; + +/** + * AvatarInitials Component + * + * This component returns the avatar for the given initials + * @param {InitialsProps} props - the props for the component. + * @property {string} initials - (optional) the initials for which the avatar is generated. + * @property {string} username - (optional) the username is used to determine the avatar color. + * @property {SxProps} sx - the styling properties of the parent component. + * @property {number} height - (optional) the height of the avatar. + * @property {number} width - (optional) the width of the avatar. + * @returns {ReactElement} The avatar initials component. + */ +const AvatarInitials = ({ + initials = '', + username = '', + sx = {}, + height = 80, + width = 80, + 'data-testid': dataTestId, +}: InitialsProps): ReactElement => { + const diameter = Math.min(height, width) * 0.5; + + return ( + + {initials} + + ); +}; + +export default AvatarInitials; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/index.tsx new file mode 100644 index 00000000..1576e2ec --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/AvatarInitials/index.tsx @@ -0,0 +1,3 @@ +import AvatarInitials from './AvatarInitials'; + +export default AvatarInitials; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx new file mode 100644 index 00000000..780bec74 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeAll } from 'vitest'; +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; +import enTranslations from '../../../../locales/en.json'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const translations: Record = { + 'backgroundEffects.invalidFileType': enTranslations['backgroundEffects.invalidFileType'], + 'backgroundEffects.fileTooLarge': enTranslations['backgroundEffects.fileTooLarge'], + 'backgroundEffects.maxSize': enTranslations['backgroundEffects.maxSize'], + 'backgroundEffects.addBackground': enTranslations['backgroundEffects.addBackground'], + }; + + let translation = translations[key] || key; + + if (options && typeof translation === 'string') { + Object.keys(options).forEach((param) => { + translation = translation.replace(`{{${param}}}`, String(options[param])); + }); + } + + return translation; + }, + }), +})); + +vi.mock('@utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + storageError: '', + handleImageFromFile: vi.fn(() => ({ + dataUrl: 'data:image/png;base64,MOCKED', + })), + handleImageFromLink: vi.fn(() => ({ + dataUrl: 'data:image/png;base64,MOCKED_LINK', + })), + }), +})); + +describe('AddBackgroundEffectLayout', () => { + const cb = vi.fn(); + + beforeAll(() => { + vi.clearAllMocks(); + }); + + it('should render', () => { + render( + + ); + expect(screen.getByTestId('background-add-background')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-add-image-line')).toBeInTheDocument(); + }); + + it('shows error for invalid file type', async () => { + render( + + ); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['dummy'], 'test.txt', { type: 'text/plain' }); + fireEvent.change(input, { target: { files: [file] } }); + expect( + await screen.findByText(/Only JPG, PNG, GIF, or BMP images are allowed/i) + ).toBeInTheDocument(); + }); + + it('shows error for file size too large', async () => { + render( + + ); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'.repeat(3 * 1024 * 1024)], 'big.png', { type: 'image/png' }); + Object.defineProperty(file, 'size', { value: 3 * 1024 * 1024 }); + fireEvent.change(input, { target: { files: [file] } }); + expect(await screen.findByText(/Image must be less than 2MB/i)).toBeInTheDocument(); + }); + + it('handles valid image file upload', async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['dummy'], 'test.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(cb).toHaveBeenCalledWith('data:image/png;base64,MOCKED')); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx new file mode 100644 index 00000000..164bd30e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx @@ -0,0 +1,115 @@ +import { ChangeEvent, ReactElement, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../../../env'; +import { ALLOWED_TYPES, MAX_SIZE_MB } from '../../../../utils/constants'; +import useImageStorage from '@utils/useImageStorage/useImageStorage'; +import SelectableOption from '@components/BackgroundEffects/SelectableOption'; + +export type AddBackgroundEffectLayoutProps = { + customBackgroundImageChange: (dataUrl: string) => void; + backgroundSelected: string; +}; + +/** + * AddBackgroundEffectLayout Component + * + * This component manages the UI for adding background effects via file upload. + * @param {AddBackgroundEffectLayoutProps} props - The props for the component. + * @property {Function} customBackgroundImageChange - Callback function to handle background image change. + * @property {string} backgroundSelected - The currently selected background effect key. + * @returns {ReactElement} The add background effect layout component. + */ +const AddBackgroundEffectLayout = ({ + customBackgroundImageChange, + backgroundSelected, +}: AddBackgroundEffectLayoutProps): ReactElement => { + const [fileError, setFileError] = useState(''); + const [showError, setShowError] = useState(false); + const { storageError, handleImageFromFile } = useImageStorage(); + const { t } = useTranslation(); + const fileInputRef = useRef(null); + + const handleFileChange = async (e: ChangeEvent) => { + const { files } = e.target; + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + if (!file) { + return; + } + + if (!ALLOWED_TYPES.includes(file.type)) { + setFileError(t('backgroundEffects.invalidFileType')); + setShowError(true); + return; + } + + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + setFileError(t('backgroundEffects.fileTooLarge', { maxSize: MAX_SIZE_MB })); + setShowError(true); + return; + } + + try { + const newImage = await handleImageFromFile(file); + if (newImage) { + setFileError(''); + setShowError(false); + customBackgroundImageChange(newImage.dataUrl); + } + } catch { + setFileError(t('backgroundEffects.processingError')); + setShowError(true); + } + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + const handleCloseError = () => { + setFileError(''); + setShowError(false); + }; + + const errorMessage = fileError || storageError; + + return ( + + + + } + /> + + + + {errorMessage} + + + + ); +}; + +export default AddBackgroundEffectLayout; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx new file mode 100644 index 00000000..ae8ccca1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx @@ -0,0 +1,3 @@ +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; + +export default AddBackgroundEffectLayout; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.spec.tsx new file mode 100644 index 00000000..4ce68f1d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.spec.tsx @@ -0,0 +1,60 @@ +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ReactElement } from 'react'; +import BackgroundEffectOptions from './BackgroundEffectOptions'; +import { makeTestProvider, providers } from '@test/providers'; +import { setupWindowNavigatorMock, makeMediaStreamMock } from '@web-test/fixtures'; + +describe('BackgroundEffectOptions', () => { + beforeEach(() => { + vi.clearAllMocks(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + getDisplayMedia: Promise.resolve(makeMediaStreamMock({})), + getSupportedConstraints: vi.fn().mockReturnValue({}), + }, + }); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders background options grid with effects and gallery', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-remove-line')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-blur-line')).toBeInTheDocument(); + + expect(screen.getByAltText('Bookshelf Room')).toBeInTheDocument(); + expect(screen.getByAltText('Busy Room')).toBeInTheDocument(); + }); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ]); + + return { + ...context, + ...renderBase(ui, { + wrapper, + }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.tsx new file mode 100644 index 00000000..870cfcaa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/BackgroundEffectOptions.tsx @@ -0,0 +1,63 @@ +import { ReactElement } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import EffectOptionButtons from '../EffectOptionButtons/EffectOptionButtons'; +import BackgroundGallery from '../BackgroundGallery/BackgroundGallery'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '@utils/constants'; + +type BackgroundEffectOptionsProps = { + mode: 'meeting' | 'waiting'; +}; + +/** + * BackgroundEffectOptions Component + * + * This component manages the tabs for background effects, including selecting existing backgrounds + * and adding new ones. + * @param {BackgroundEffectOptionsProps} props - The props for the component. + * @property {string} mode - The mode of the background effect ('meeting' or 'waiting'). + * @returns {ReactElement} The background effect tabs component. + */ +const BackgroundEffectOptions = ({ mode }: BackgroundEffectOptionsProps): ReactElement => { + return ( + + + + + + + + + ); +}; + +export default BackgroundEffectOptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/Index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/Index.tsx new file mode 100644 index 00000000..cee563bc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectOptions/Index.tsx @@ -0,0 +1 @@ +export { default } from './BackgroundEffectOptions'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx new file mode 100644 index 00000000..e0585ac0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -0,0 +1,194 @@ +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers } from '@test/providers'; +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; +import enTranslations from '../../../locales/en.json'; +import composeProviders from '@web/helpers/composeProviders'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import { setupWindowNavigatorMock, makeMediaStreamMock } from '@web-test/fixtures'; + +const applyBackgroundFilterMock = vi.fn(() => Promise.resolve()); + +vi.mock('../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter', () => ({ + default: () => applyBackgroundFilterMock(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'backgroundEffects.title': enTranslations['backgroundEffects.title'], + 'button.cancel': enTranslations['button.cancel'], + 'button.apply': enTranslations['button.apply'], + }; + return translations[key] || key; + }, + }), +})); + +describe('BackgroundEffectsLayout (Meeting room)', () => { + const handleClose = vi.fn(); + + beforeEach(() => { + handleClose.mockClear(); + applyBackgroundFilterMock.mockClear(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getTracks: vi.fn().mockReturnValue([]), + getAudioTracks: vi.fn().mockReturnValue([]), + getVideoTracks: vi.fn().mockReturnValue([]), + }) + ), + }, + permissions: { + query: vi.fn().mockResolvedValue({ state: 'granted' } as PermissionStatus), + }, + }); + }); + + it('renders when open', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('right-panel-title')).toBeInTheDocument()); + expect(screen.getByTestId('right-panel-title')).toHaveTextContent('Video effects'); + expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); + expect(screen.getByTestId('background-none')).toBeInTheDocument(); + expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-cancel-button')).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-apply-button')).toBeInTheDocument(); + }); + + it('does not render when closed', async () => { + const { container } = render( + + ); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('calls handleClose when Cancel is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-effect-cancel-button')); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls handleClose and changeBackground when Apply is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-effect-apply-button')); + expect(handleClose).toHaveBeenCalled(); + expect(applyBackgroundFilterMock).toHaveBeenCalled(); + }); + + it('calls setBackgroundSelected when effect option none is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-none')); + }); + + it('calls setBackgroundSelected when a background gallery option is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-bg8')); + }); + + it('displays correct English title, subtitle, cancel, and apply actions', async () => { + render(); + await waitFor(() => expect(screen.getByText('Cancel')).toBeInTheDocument()); + expect(screen.getByText('Apply')).toBeInTheDocument(); + }); +}); + +describe('BackgroundEffects (Waiting Room)', () => { + const handleClose = vi.fn(); + + beforeEach(() => { + handleClose.mockClear(); + applyBackgroundFilterMock.mockClear(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + permissions: { + query: vi.fn().mockResolvedValue({ state: 'granted' } as PermissionStatus), + }, + }); + }); + + it('renders when open', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('background-video-container')).toBeInTheDocument() + ); + expect(screen.getByTestId('background-none')).toBeInTheDocument(); + expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument(); + }); + + it('does not render when closed', async () => { + const { container } = render( + + ); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it('calls handleClose when Cancel is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-effect-cancel-button')); + expect(handleClose).toHaveBeenCalled(); + }); + + it('calls handleClose and changeBackground when Apply is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-effect-apply-button')); + expect(handleClose).toHaveBeenCalled(); + expect(applyBackgroundFilterMock).toHaveBeenCalled(); + }); + + it('calls setBackgroundSelected when effect option none is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-none')); + }); + + it('calls setBackgroundSelected when a background gallery option is clicked', async () => { + render(); + await userEvent.click(screen.getByTestId('background-bg8')); + }); + + it('displays correct English title, subtitle, cancel, and apply actions', async () => { + render(); + await waitFor(() => expect(screen.getByText('Cancel')).toBeInTheDocument()); + expect(screen.getByText('Apply')).toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper: roomWrapper, ...context } = makeTestProvider([ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.previewPublisher, + providers.runtime, + ]); + + const wrapper = composeProviders(SuspenseBoundary, roomWrapper); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx new file mode 100644 index 00000000..75db6854 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -0,0 +1,165 @@ +import { ReactElement, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import usePublisherContext from '../../../hooks/usePublisherContext'; +import BackgroundVideoContainer from '../BackgroundVideoContainer'; +import BackgroundEffectOptions from '../BackgroundEffectOptions/BackgroundEffectOptions'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; +import RightPanelTitle from '../../MeetingRoom/RightPanel/RightPanelTitle'; +import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; + +export type BackgroundEffectsLayoutProps = { + isOpen: boolean; + handleClose: () => void; + mode: 'meeting' | 'waiting'; +}; + +/** + * BackgroundEffectsLayout Component + * + * This component provides a layout for managing background effects, including video preview, + * effect selection tabs, and action buttons. + * @param {BackgroundEffectsLayoutProps} props - The properties for the component. + * @property {boolean} isOpen - Whether the background effects layout is open. + * @property {Function} handleClose - Function to close the background effects layout. + * @property {string} mode - The mode of the background effects ('meeting' or 'waiting'). + * @returns {ReactElement | false} The background effects layout component or false if not open. + */ +const BackgroundEffectsLayout = ({ + isOpen, + handleClose, + mode, +}: BackgroundEffectsLayoutProps): ReactElement | false => { + const { t } = useTranslation(); + + const isShortScreen = useMediaQuery('(max-height:825px)'); + const publisherContext = usePublisherContext(); + const previewPublisherContext = usePreviewPublisherContext(); + + const { publisher, changeBackground, isVideoEnabled } = + mode === 'meeting' ? publisherContext : previewPublisherContext; + + const { + publisherVideoElement, + changeBackground: changeBackgroundPreview, + backgroundSelected, + setBackgroundSelected, + } = useBackgroundPublisherContext(); + + const handleApplyBackgroundSelect = () => { + void changeBackground(backgroundSelected); + handleClose(); + }; + + const setInitialBackgroundReplacement = useCallback(() => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + setBackgroundSelected(selectedBackgroundOption); + return selectedBackgroundOption; + }, [publisher, setBackgroundSelected]); + + useEffect(() => { + if (isOpen) { + const currentOption = setInitialBackgroundReplacement(); + void changeBackgroundPreview(currentOption); + } + }, [isOpen, publisher, changeBackgroundPreview, setInitialBackgroundReplacement]); + + const buttonGroup = ( + + + + + ); + + if (!isOpen) { + return false; + } + + // MeetingRoom layout + if (mode === 'meeting') { + return ( + + + + + + + + + + {buttonGroup} + + ); + } + + // WaitingRoom layout + return ( + <> + + + + + + + + + + {buttonGroup} + + ); +}; + +export default BackgroundEffectsLayout; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/index.tsx new file mode 100644 index 00000000..cdeb11f9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundEffectsLayout/index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsLayout from './BackgroundEffectsLayout'; + +export default BackgroundEffectsLayout; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx new file mode 100644 index 00000000..402c1295 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx @@ -0,0 +1,210 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BackgroundGallery from './BackgroundGallery'; +import enTranslations from '../../../locales/en.json'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import { ReactElement } from 'react'; +import { + setupWindowNavigatorMock, + makeMediaStreamMock, + makeMediaDeviceInfos, +} from '@web-test/fixtures'; +import { mediaDevices$ } from '@core/stores'; + +const mockDevices = makeMediaDeviceInfos(); + +const customImages = [ + { id: 'custom1', dataUrl: 'data:image/png;base64,custom1' }, + { id: 'custom2', dataUrl: 'data:image/png;base64,custom2' }, +]; + +const backgrounds = [ + { + id: 'bg4', + file: 'hogwarts.jpg', + name: enTranslations['backgroundEffects.backgrounds.hogwarts'], + }, + { id: 'bg5', file: 'library.jpg', name: enTranslations['backgroundEffects.backgrounds.library'] }, + { + id: 'bg6', + file: 'new-york.jpg', + name: enTranslations['backgroundEffects.backgrounds.newYork'], + }, + { id: 'bg7', file: 'plane.jpg', name: enTranslations['backgroundEffects.backgrounds.plane'] }, +]; + +const mockDeleteImageFromStorage = vi.fn(); +const mockGetImagesFromStorage = vi.fn(() => customImages); +const mockDeleteCustomImage = vi.fn(); +const mockHandleBackgroundChange = vi.fn(); + +vi.mock('@utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + getImagesFromStorage: mockGetImagesFromStorage, + deleteImageFromStorage: mockDeleteImageFromStorage, + }), +})); + +describe('BackgroundGallery', () => { + beforeEach(() => { + vi.clearAllMocks(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(mockDevices), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + }); + + // Initialize the mediaDevices$ store with mock devices to prevent useSyncPublisherDevices from disabling video/audio + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: mockDevices, + })); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders all built-in backgrounds as selectable options', async () => { + render(); + await waitFor(() => { + backgrounds.forEach((bg) => { + expect(screen.getByTestId(`background-${bg.id}`)).toBeInTheDocument(); + }); + }); + }); + + it('renders custom images as selectable options', async () => { + render(); + await waitFor(() => { + customImages.forEach((img) => { + expect(screen.getByTestId(`background-${img.id}`)).toBeInTheDocument(); + }); + }); + }); + + it('sets the selected built-in background', async () => { + render(, { + backgroundPublisherContext: { + __interceptor: (context) => { + context.handleBackgroundChange = mockHandleBackgroundChange; + }, + }, + }); + const duneViewOption = screen.getByTestId('background-bg3'); + await userEvent.click(duneViewOption); + expect(mockHandleBackgroundChange).toHaveBeenCalledWith('dune-view.jpg'); + }); + + it('sets the selected custom image', async () => { + render(, { + backgroundPublisherContext: { + __interceptor: (context) => { + context.handleBackgroundChange = mockHandleBackgroundChange; + }, + }, + }); + const customOption = screen.getByTestId('background-custom1'); + await userEvent.click(customOption); + expect(mockHandleBackgroundChange).toHaveBeenCalledWith('data:image/png;base64,custom1'); + }); + + it('marks the built-in background as selected', async () => { + render(, { + backgroundPublisherContext: { + __onCreated: (context) => { + context.backgroundSelected = 'plane.jpg'; + }, + }, + }); + const planeOption = screen.getByTestId('background-bg7'); + await waitFor(() => { + expect(planeOption.getAttribute('aria-pressed')).toBe('true'); + }); + }); + + it('marks the custom image as selected', async () => { + render(, { + backgroundPublisherContext: { + __onCreated: (context) => { + context.backgroundSelected = 'data:image/png;base64,custom2'; + }, + }, + }); + const customOption = screen.getByTestId('background-custom2'); + + await waitFor(() => { + expect(customOption.getAttribute('aria-pressed')).toBe('true'); + }); + }); + + it('calls onDelete when deleting a custom image', async () => { + render(, { + backgroundPublisherContext: { + __interceptor: (context) => { + context.deleteCustomImage = mockDeleteCustomImage; + }, + }, + }); + const deleteButtons = screen.getAllByLabelText('Delete custom background'); + await userEvent.click(deleteButtons[0]); + expect(mockDeleteCustomImage).toHaveBeenCalledWith('custom1'); + }); + + it("doesn't delete custom image if it's selected", async () => { + render(, { + backgroundPublisherContext: { + __onCreated: (context) => { + context.backgroundSelected = 'data:image/png;base64,custom1'; + }, + }, + }); + const deleteButton = screen.getByTestId('background-delete-custom1'); + await userEvent.click(deleteButton); + expect(mockDeleteCustomImage).not.toHaveBeenCalled(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; +}; + +function render( + ui: ReactElement, + { userContext, sessionContext, publisherContext, backgroundPublisherContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx new file mode 100644 index 00000000..95d2f9fe --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx @@ -0,0 +1,118 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import VividIcon from '@ui/VividIcon'; +import { BACKGROUNDS_PATH } from '@utils/constants'; +import SelectableOption from '../SelectableOption'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; + +/** + * Renders a group of selectable images for background replacement in a meeting room. + * + * Each button represents a different background image option. + * @returns {ReactElement} A horizontal stack of selectable option buttons. + */ +const BackgroundGallery = (): ReactElement => { + const { backgroundSelected, handleBackgroundChange, customImages, deleteCustomImage } = + useBackgroundPublisherContext(); + const { t } = useTranslation(); + + const backgrounds = [ + { + id: 'bg1', + file: 'bookshelf-room.jpg', + name: t('backgroundEffects.backgrounds.bookshelfRoom'), + }, + { id: 'bg2', file: 'busy-room.jpg', name: t('backgroundEffects.backgrounds.busyRoom') }, + { id: 'bg3', file: 'dune-view.jpg', name: t('backgroundEffects.backgrounds.duneView') }, + { id: 'bg4', file: 'hogwarts.jpg', name: t('backgroundEffects.backgrounds.hogwarts') }, + { id: 'bg5', file: 'library.jpg', name: t('backgroundEffects.backgrounds.library') }, + { id: 'bg6', file: 'new-york.jpg', name: t('backgroundEffects.backgrounds.newYork') }, + { id: 'bg7', file: 'plane.jpg', name: t('backgroundEffects.backgrounds.plane') }, + { id: 'bg8', file: 'white-room.jpg', name: t('backgroundEffects.backgrounds.whiteRoom') }, + ]; + + return ( + <> + {customImages.map(({ id, dataUrl }) => { + const isSelected = backgroundSelected === dataUrl; + return ( + + handleBackgroundChange(dataUrl)} + image={dataUrl} + > + + { + e.stopPropagation(); + if (!isSelected) { + deleteCustomImage(id); + } + }} + size="small" + className={classNames({ + 'text-vera-text-disabled bg-vera-disabled cursor-default': isSelected, + 'bg-vera-dark-grey-opacity! text-vera-on-dark-grey hover:bg-vera-dark-grey! cursor-pointer': + !isSelected, + })} + sx={{ + position: 'absolute', + top: -8, + right: -8, + zIndex: 10, + }} + > + + + + + + ); + })} + + {backgrounds.map((bg) => { + const path = `${BACKGROUNDS_PATH}/${bg.file}`; + return ( + handleBackgroundChange(bg.file)} + image={path} + /> + ); + })} + + ); +}; + +export default BackgroundGallery; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx new file mode 100644 index 00000000..e7a570f1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundGallery/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundGallery from './BackgroundGallery'; + +export default BackgroundGallery; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx new file mode 100644 index 00000000..4bfb886f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.spec.tsx @@ -0,0 +1,36 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import BackgroundVideoContainer from './BackgroundVideoContainer'; + +vi.mock('../../../utils/waitUntilPlaying', () => ({ + __esModule: true, + default: vi.fn(() => Promise.resolve()), +})); + +describe('BackgroundVideoContainer', () => { + it('shows message when video is not enabled', () => { + render(); + expect(screen.getByText(/Your camera is turned off/i)).toBeInTheDocument(); + }); + + it('renders video element when video is enabled', async () => { + const videoEl = document.createElement('video'); + act(() => { + render(); + }); + await waitFor(() => { + expect(videoEl.classList.contains('video__element')).toBe(true); + expect(videoEl.title).toBe('publisher-preview'); + }); + }); + + it('shows loading spinner while video is loading', async () => { + const videoEl = document.createElement('video'); + act(() => { + render(); + }); + await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx new file mode 100644 index 00000000..a3867c54 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -0,0 +1,123 @@ +import { useRef, useState, useEffect, ReactElement } from 'react'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTranslation } from 'react-i18next'; +import CircularProgress from '@mui/material/CircularProgress'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import waitUntilPlaying from '../../../utils/waitUntilPlaying'; +import useIsTabletViewport from '../../../hooks/useIsTabletViewport'; + +export type BackgroundVideoContainerProps = { + isFixedWidth?: boolean; + publisherVideoElement?: HTMLObjectElement | HTMLVideoElement | undefined; + isParentVideoEnabled?: boolean; +}; + +/** + * Component to render the video element for the background replacement preview publisher. + * @param {BackgroundVideoContainerProps} props - The properties for the component + * @property {boolean} isFixedWidth - Whether to apply a fixed width to the video element + * @property {HTMLObjectElement | HTMLVideoElement} publisherVideoElement - The video element to display + * @property {boolean} isParentVideoEnabled - Whether the parent video is enabled + * @returns {ReactElement} The rendered video container element + */ +const BackgroundVideoContainer = ({ + isFixedWidth = false, + publisherVideoElement, + isParentVideoEnabled = false, +}: BackgroundVideoContainerProps): ReactElement => { + const { t } = useTranslation(); + const containerRef = useRef(null); + const [isVideoLoading, setIsVideoLoading] = useState(true); + const isSMViewport = useMediaQuery(`(max-width:500px)`); + const isMDViewport = useMediaQuery(`(max-width:768px)`); + const isTabletViewport = useIsTabletViewport(); + const isLGViewport = useMediaQuery(`(max-width:1199px)`); + + useEffect(() => { + if (publisherVideoElement && containerRef.current) { + containerRef.current.appendChild(publisherVideoElement); + const myVideoElement = publisherVideoElement as HTMLElement; + myVideoElement.classList.add('video__element', 'rounded-vera-large'); + + // eslint-disable-next-line react-hooks/immutability + myVideoElement.title = 'publisher-preview'; + + let width = '100%'; + if ( + (isFixedWidth && isTabletViewport) || + (!isFixedWidth && isMDViewport) || + (isLGViewport && isFixedWidth) + ) { + width = '95%'; + } + + Object.assign(myVideoElement.style, { + maxHeight: isTabletViewport ? '80%' : '450px', + width, + marginLeft: 'auto', + marginRight: 'auto', + marginBottom: '1px', + transform: 'scaleX(-1)', + objectFit: 'contain', + aspectRatio: '16 / 9', + }); + + void waitUntilPlaying(publisherVideoElement).then(() => { + setIsVideoLoading(false); + }); + } + }, [ + isTabletViewport, + isMDViewport, + isSMViewport, + publisherVideoElement, + isFixedWidth, + isParentVideoEnabled, + isLGViewport, + ]); + + let containerWidth = '100%'; + if (isFixedWidth) { + containerWidth = isTabletViewport ? '80%' : '95%'; + } else if (isMDViewport) { + containerWidth = '80%'; + } + + return ( + + {!isParentVideoEnabled && ( + + + {t('backgroundEffects.video.disabled')} + + + )} + {isParentVideoEnabled &&
        } + {isVideoLoading && isParentVideoEnabled && ( + + + + )} + + ); +}; + +export default BackgroundVideoContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx new file mode 100644 index 00000000..271cd2f1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/index.tsx @@ -0,0 +1,3 @@ +import BackgroundVideoContainer from './BackgroundVideoContainer'; + +export default BackgroundVideoContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx new file mode 100644 index 00000000..5e093583 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.spec.tsx @@ -0,0 +1,125 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactElement } from 'react'; +import EffectOptionButtons from './EffectOptionButtons'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import { setupWindowNavigatorMock, makeMediaStreamMock } from '@web-test/fixtures'; + +describe('EffectOptionButtons', () => { + const mockHandleBackgroundChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + }); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders all effect options', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-remove-line')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-blur-line')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-blur-solid')).toBeInTheDocument(); + }); + }); + + it('marks the selected option as selected', async () => { + render(, { + backgroundPublisherContext: { + __onCreated: (context) => { + context.backgroundSelected = 'low-blur'; + }, + }, + }); + await waitFor(() => { + const selectedOption = screen.getByTestId('background-low-blur'); + expect(selectedOption).toBeInTheDocument(); + }); + }); + + it('sets the selected background', async () => { + render(, { + backgroundPublisherContext: { + __interceptor: (context) => { + context.handleBackgroundChange = mockHandleBackgroundChange; + }, + }, + }); + await waitFor(() => { + expect(screen.getByTestId('background-low-blur')).toBeInTheDocument(); + }); + const lowBlur = screen.getByTestId('background-low-blur'); + await userEvent.click(lowBlur); + await waitFor(() => { + expect(mockHandleBackgroundChange).toHaveBeenCalledWith('low-blur'); + }); + }); + + it('sets the selected background with high blur-sm', async () => { + render(, { + backgroundPublisherContext: { + __interceptor: (context) => { + context.handleBackgroundChange = mockHandleBackgroundChange; + }, + }, + }); + await waitFor(() => { + expect(screen.getByTestId('background-high-blur')).toBeInTheDocument(); + }); + const highBlur = screen.getByTestId('background-high-blur'); + await userEvent.click(highBlur); + await waitFor(() => { + expect(mockHandleBackgroundChange).toHaveBeenCalledWith('high-blur'); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; +}; + +function render( + ui: ReactElement, + { userContext, sessionContext, publisherContext, backgroundPublisherContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx new file mode 100644 index 00000000..c4e5a3b4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx @@ -0,0 +1,57 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import VividIcon from '@ui/VividIcon'; +import SelectableOption from '../SelectableOption'; +import AddBackgroundEffectLayout from '../AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; + +/** + * Renders a group of selectable buttons for background effects in a room. + * + * Each button represents a different background effect option. + * @returns {ReactElement} A horizontal stack of selectable option buttons. + */ +const EffectOptionButtons = (): ReactElement => { + const { backgroundSelected, handleBackgroundChange, handleAddCustomImage } = + useBackgroundPublisherContext(); + const { t } = useTranslation(); + const options = [ + { + key: 'none', + icon: , + name: t('backgroundEffects.removeBackground'), + }, + { + key: 'low-blur', + icon: , + name: t('backgroundEffects.slightBlur'), + }, + { + key: 'high-blur', + icon: , + name: t('backgroundEffects.strongBlur'), + }, + ]; + return ( + <> + {options.map(({ key, icon, name }) => ( + { + handleBackgroundChange(key); + }} + icon={icon} + /> + ))} + + + ); +}; + +export default EffectOptionButtons; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx new file mode 100644 index 00000000..15616c2f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/EffectOptionButtons/Index.tsx @@ -0,0 +1,3 @@ +import EffectOptionButtons from './EffectOptionButtons'; + +export default EffectOptionButtons; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx new file mode 100644 index 00000000..12b88bda --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SelectableOption from './SelectableOption'; + +describe('SelectableOption', () => { + it('renders with icon when image is not provided', () => { + render( + {}} + id="icon-option" + icon={Icon} + /> + ); + expect(screen.getByTestId('background-icon-option')).toBeInTheDocument(); + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('renders with image when image is provided', () => { + render( + {}} + title="background" + id="img-option" + image="/test.jpg" + /> + ); + expect(screen.getByTestId('background-img-option')).toBeInTheDocument(); + expect(screen.getByAltText('background')).toHaveAttribute('src', '/test.jpg'); + }); + + it('calls onClick when clicked', async () => { + const handleClick = vi.fn(); + render( + Click} + /> + ); + await userEvent.click(screen.getByTestId('background-clickable')); + expect(handleClick).toHaveBeenCalled(); + }); + + it('shows selected aria-pressed when selected', () => { + render( + {}} id="selected" icon={Selected} /> + ); + const option = screen.getByTestId('background-selected'); + expect(option).toHaveAttribute('aria-pressed', 'true'); + }); + + it('shows the title in the tooltip', async () => { + render( + {}} + id="with-title" + icon={Icon} + title="My Tooltip Title" + /> + ); + await userEvent.hover(screen.getByTestId('background-with-title')); + expect(screen.getByLabelText('My Tooltip Title')).toBeInTheDocument(); + }); + + it('renders children inside the option', () => { + render( + {}} + id="with-children" + icon={Icon} + > + Child Content + + ); + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx new file mode 100644 index 00000000..1ee02b8e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx @@ -0,0 +1,104 @@ +import { ReactElement, ReactNode } from 'react'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; + +export type SelectableOptionProps = { + isSelected: boolean; + onClick: () => void; + id: string; + icon?: ReactNode; + title?: string; + image?: string; + size?: number; + isDisabled?: boolean; + children?: ReactNode; +}; + +/** + * Renders a selectable option with an icon or image. + * + * The option can be selected or disabled, and has a hover effect. + * @param {SelectableOptionProps} props - The properties for the component + * @property {boolean} isSelected - Whether the option is selected + * @property {Function} onClick - Function to call when the option is clicked + * @property {string} id - Unique identifier for the option + * @property {ReactNode} icon - Icon to display in the option + * @property {string} title - Title to display in the option + * @property {string} image - Image URL to display in the option + * @property {number} size - Size of the option (default is DEFAULT_SELECTABLE_OPTION_WIDTH) + * @property {boolean} isDisabled - Whether the option is disabled + * @property {ReactNode} children - Additional content to render inside the option + * @returns {ReactElement} A selectable option element + */ +const SelectableOption = ({ + isSelected, + onClick, + id, + icon, + title, + image, + size = DEFAULT_SELECTABLE_OPTION_WIDTH, + children, + ...otherProps +}: SelectableOptionProps): ReactElement => { + return ( + + + + + {image ? ( + {title} + ) : ( + icon + )} + + + {children} + + + ); +}; + +export default SelectableOption; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx new file mode 100644 index 00000000..62c1d319 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BackgroundEffects/SelectableOption/index.tsx @@ -0,0 +1,3 @@ +import SelectableOption from './SelectableOption'; + +export default SelectableOption; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.spec.tsx new file mode 100644 index 00000000..1d568444 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.spec.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import Banner from './Banner'; + +describe('Banner', () => { + it('renders the banner logo', () => { + render( + + + + ); + + const bannerLogo = screen.getByTestId('banner-logo'); + expect(bannerLogo).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.tsx new file mode 100644 index 00000000..d8814243 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/Banner.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react'; +import Header from '@ui/Header'; +import BannerLogo from '../BannerLogo'; +import BannerLanguage from '../BannerLanguage'; + +/** + * Banner Component + * + * This component returns a banner that includes a logo, current date/time, language selector, and some links. + * @returns {ReactElement} - the banner component. + */ +const Banner = (): ReactElement => { + return ( +
        +
        + +
        + +
        + +
        +
        + ); +}; + +export default Banner; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/index.tsx new file mode 100644 index 00000000..b0c13faa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Banner/index.tsx @@ -0,0 +1,3 @@ +import Banner from './Banner'; + +export default Banner; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.spec.tsx new file mode 100644 index 00000000..56940b9c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.spec.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import BannerLanguage from './BannerLanguage'; + +describe('BannerLanguage', () => { + it('renders the banner language component', () => { + render( + + + + ); + + const bannerLanguage = screen.getByTestId('banner-language'); + expect(bannerLanguage).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.tsx new file mode 100644 index 00000000..c1280f5b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/BannerLanguage.tsx @@ -0,0 +1,21 @@ +import Box from '@mui/material/Box'; +import LanguageSelector from '../LanguageSelector'; +import type { BoxProps } from '@mui/material'; + +type BannerLanguageProps = BoxProps; + +/** + * BannerLanguage Component + * + * This component provides a language selection feature for the banner. + * @returns {ReactElement} The BannerLanguage component. + */ +const BannerLanguage: React.FC = (props) => { + return ( + + + + ); +}; + +export default BannerLanguage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/index.tsx new file mode 100644 index 00000000..4ceb444c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLanguage/index.tsx @@ -0,0 +1,3 @@ +import BannerLanguage from './BannerLanguage'; + +export default BannerLanguage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.spec.tsx new file mode 100644 index 00000000..8a0f1cb3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.spec.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import BannerLogo from './BannerLogo'; + +describe('BannerLogo', () => { + it('renders the vonage desktop logo', () => { + render( + + + + ); + + const vonageLogo = screen.getByAltText('Vonage-desktop-logo'); + expect(vonageLogo).toBeInTheDocument(); + expect(vonageLogo.src).toContain('/images/vonage-logo-desktop.svg'); + }); + + it('renders clickable logo with cursor pointer', () => { + render( + + + + ); + + const logo = screen.getByTestId('banner-logo-image'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveStyle({ cursor: 'pointer' }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.tsx new file mode 100644 index 00000000..d9ee766c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/BannerLogo.tsx @@ -0,0 +1,45 @@ +import { ReactElement } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import useIsTabletViewport from '@hooks/useIsTabletViewport'; + +/** + * BannerLogo Component + * + * This component returns a logo for the banner that navigates to the parent route when clicked. + * @returns {ReactElement} - the banner logo component. + */ +const BannerLogo = (): ReactElement => { + const isTablet = useIsTabletViewport(); + const navigate = useNavigate(); + const handleClick = () => { + navigate('..'); + }; + + return ( + + { + if (e.key === 'Enter' || e.key === ' ') { + handleClick(); + } + }} + role="button" + tabIndex={0} + sx={{ + height: { xs: 40, md: 72 }, + display: 'block', + cursor: 'pointer', + }} + /> + + ); +}; + +export default BannerLogo; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/index.tsx new file mode 100644 index 00000000..cce7558f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/BannerLogo/index.tsx @@ -0,0 +1,3 @@ +import BannerLogo from './BannerLogo'; + +export default BannerLogo; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.spec.tsx new file mode 100644 index 00000000..531df2aa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, Mock } from 'vitest'; +import DeviceAccessAlert from './DeviceAccessAlert'; +import * as platform from '@web/platform'; +import { DEVICE_ACCESS_STATUS } from '../../utils/constants'; + +vi.mock('@web/platform', async () => { + const actual = await vi.importActual('@web/platform'); + return { + ...actual, + isWebKit: vi.fn(), + }; +}); + +describe('DeviceAccessAlert', () => { + it('should display the correct message and image when access status is PENDING', () => { + (platform.isWebKit as Mock).mockReturnValue(false); + render(); + + // Check that the correct message is displayed + expect( + screen.getByText( + 'To join the video room, your browser will request access to your camera and microphone.' + ) + ).toBeInTheDocument(); + + // Check that the correct image is displayed + const imgElement = screen.getByAltText('Access Dialog'); + expect(imgElement).toHaveAttribute('src', '/images/access-dialog-pending.png'); + }); + + it('should display the correct message and image when access status is DENIED', () => { + (platform.isWebKit as Mock).mockReturnValue(false); + render(); + + // Check that the correct message is displayed + expect( + screen.getByText( + "It seems your browser is blocked from accessing your camera and/or microphone. Reset the permission state through your browser's UI." + ) + ).toBeInTheDocument(); + + // Check that the correct image is displayed + const imgElement = screen.getByAltText('Access Dialog'); + expect(imgElement).toHaveAttribute('src', '/images/access-dialog-rejected.png'); + }); + + it('should not display anything when access status is set to null', () => { + (platform.isWebKit as Mock).mockReturnValue(false); + render(); + + // Check that there is no alert displayed + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('should not display anything when the user is using Safari', () => { + (platform.isWebKit as Mock).mockReturnValue(true); + render(); + + // Check that there is no alert displayed + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx new file mode 100644 index 00000000..064ec2c3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx @@ -0,0 +1,77 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DEVICE_ACCESS_STATUS } from '../../utils/constants'; +import { isWebKit } from '@web/platform'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import AlertTitle from '@mui/material/AlertTitle'; + +export type DeviceAccessAlertProps = { + accessStatus: string | null; +}; + +/** + * A component that displays an alert message asking the user to grant permissions + * or informing them that they have declined the permissions. + * @param {DeviceAccessAlertProps} props - the props for the component. + * @property {string | null} accessStatus - the current access status. + * @returns {ReactElement | false} - The rendered DeviceAccessAlert component if not Safari + */ +const DeviceAccessAlert = ({ accessStatus }: DeviceAccessAlertProps): ReactElement | false => { + const { t } = useTranslation(); + const messageToDisplay = + accessStatus === DEVICE_ACCESS_STATUS.PENDING + ? t('deviceAccessAlert.askDeviceMessage') + : t('deviceAccessAlert.deniedDeviceMessage'); + const imgToDisplay = + accessStatus === DEVICE_ACCESS_STATUS.PENDING + ? '/images/access-dialog-pending.png' + : '/images/access-dialog-rejected.png'; + const severity = accessStatus === DEVICE_ACCESS_STATUS.PENDING ? 'success' : 'error'; + + return ( + !isWebKit() && ( + + {accessStatus && ( + + + {messageToDisplay} + + + + + + )} + + ) + ); +}; + +export default DeviceAccessAlert; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/index.tsx new file mode 100644 index 00000000..fb385f53 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/DeviceAccessAlert/index.tsx @@ -0,0 +1,3 @@ +import DeviceAccessAlert from './DeviceAccessAlert'; + +export default DeviceAccessAlert; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/EnvGuard.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/EnvGuard.tsx new file mode 100644 index 00000000..ff0382f3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/EnvGuard.tsx @@ -0,0 +1,16 @@ +import { envInitError } from '../../env'; + +/** + * Re-throws any error that occurred when initializing the `env` singleton so + * that a parent ErrorBoundary can catch it and show a proper fallback. + * Renders nothing when the env is healthy. + */ +const EnvGuard = () => { + if (envInitError) { + throw envInitError; + } + + return null; +}; + +export default EnvGuard; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/index.ts new file mode 100644 index 00000000..0dffe74d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/EnvGuard/index.ts @@ -0,0 +1,3 @@ +import EnvGuard from './EnvGuard'; + +export default EnvGuard; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..7d1b3767 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +type ErrorBoundaryProps = { + children: ReactNode; + fallback: (error: Error) => ReactNode; +}; + +type ErrorBoundaryState = { hasError: false } | { hasError: true; error: Error }; + +/** + * Generic React error boundary. Catches errors thrown during render inside its + * subtree and renders the provided fallback instead of a blank screen. + */ +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + return { hasError: true, error: normalizedError }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[ErrorBoundary] Caught error:', error, info); + } + + override render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/index.ts new file mode 100644 index 00000000..388e219f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ErrorBoundary/index.ts @@ -0,0 +1,3 @@ +import ErrorBoundary from './ErrorBoundary'; + +export default ErrorBoundary; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.spec.tsx new file mode 100644 index 00000000..0452a679 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.spec.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import Footer from './Footer'; + +describe('Footer', () => { + it('renders the footer content', () => { + render( + +
        + + ); + + const footerLinks = screen.getByTestId('footer-content'); + expect(footerLinks).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.tsx new file mode 100644 index 00000000..54b156a0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/Footer.tsx @@ -0,0 +1,25 @@ +import type { ReactElement } from 'react'; +import FooterLinks from '@components/FooterLinks'; + +/** + * Footer Component + * + * This component returns a footer displaying footer links. + * @returns {ReactElement} - the footer component. + */ +const Footer = (): ReactElement => { + return ( +
        +
        + +
        + +
        +
        + ); +}; + +export default Footer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/index.tsx new file mode 100644 index 00000000..ced11e52 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Footer/index.tsx @@ -0,0 +1,3 @@ +import Footer from './Footer'; + +export default Footer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.spec.tsx new file mode 100644 index 00000000..93c3d2dd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.spec.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import FooterLinks from './FooterLinks'; + +describe('FooterLinks', () => { + it('renders the footer links component', () => { + render( + + + + ); + + const footerLinks = screen.getByTestId('footer-links'); + expect(footerLinks).toBeInTheDocument(); + }); + + it('displays the app version', () => { + render( + + + + ); + + const versionElement = screen.getByTestId('app-version'); + expect(versionElement).toBeInTheDocument(); + expect(versionElement.textContent).toMatch(/^v\d+\.\d+\.\d+ \(SDK \d+\.\d+\.\d+\)$/); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.tsx new file mode 100644 index 00000000..c71747ba --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/FooterLinks.tsx @@ -0,0 +1,34 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import GHRepoButton from '../GHRepoButton'; +import getAppVersion from '@utils/getAppVersion'; +import sdkPackageInfo from '@vonage/client-sdk-video/package.json'; + +const formatDisplayVersion = (version: string): string => version.replace(/^vera-/, 'v'); + +/** + * FooterLinks Component + * + * Component holding different icon-buttons. + * @returns {ReactElement} The FooterLinks component. + */ +const FooterLinks = (): ReactElement => { + const { t } = useTranslation(); + + return ( +
        + + + {t('footer.github.title')} + + + {formatDisplayVersion(getAppVersion())} (SDK {sdkPackageInfo.version}) + +
        + ); +}; + +export default FooterLinks; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/index.tsx new file mode 100644 index 00000000..0566b087 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/FooterLinks/index.tsx @@ -0,0 +1,3 @@ +import FooterLinks from './FooterLinks'; + +export default FooterLinks; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.spec.tsx new file mode 100644 index 00000000..c8774ca0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.spec.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MemoryRouter from '@test/RouterWrapper'; +import userEvent from '@testing-library/user-event'; +import GHRepoButton from './GHRepoButton'; + +describe('GHRepoButton', () => { + it('renders a link to the GitHub repo', () => { + render( + + + + ); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', 'https://github.com/Vonage/vonage-video-react-app/'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('renders a tooltip with the correct title', async () => { + render(); + const button = screen.getByRole('button'); + await userEvent.hover(button); + const tooltip = await screen.findByText('Visit our GitHub Repo'); + expect(tooltip).toBeInTheDocument(); + }); + + it('renders the GitHub icon', () => { + render(); + const icon = screen.getByTestId('vivid-icon-github-mono'); + expect(icon).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.tsx new file mode 100644 index 00000000..3ad372db --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/GHRepoButton.tsx @@ -0,0 +1,32 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Tooltip from '@mui/material/Tooltip'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import VividIcon from '@ui/VividIcon'; + +/** + * GHRepoButton Component + * + * Displays a button with a link to the Vonage Video React App GitHub page. + * @returns {ReactElement} - The GHRepoButton component. + */ +const GHRepoButton = (): ReactElement => { + const { t } = useTranslation(); + + return ( + + + + + + + + ); +}; + +export default GHRepoButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/index.tsx new file mode 100644 index 00000000..536016b4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GHRepoButton/index.tsx @@ -0,0 +1,3 @@ +import GHRepoButton from './GHRepoButton'; + +export default GHRepoButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.spec.tsx new file mode 100644 index 00000000..8ffbedee --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.spec.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import ArchiveList from './ArchiveList'; +import { + availableArchive, + failedArchive, + pendingArchive, + archives as testArchives, +} from '../../../api/archiving/tests/data'; + +describe('ArchiveList', () => { + it('should display message if there are no archives', () => { + render(); + expect(screen.getByText("The meeting hasn't been recorded")).toBeVisible(); + }); + + it('should display message if there is an error fetching archives', () => { + render(); + expect( + screen.getByText('There was an error loading recordings for this meeting') + ).toBeVisible(); + }); + + it('should render a download button for available archives', () => { + render(); + const listItem = screen.getByTestId(`archive-list-item-${availableArchive.id}`); + expect(within(listItem).getByTestId('archive-download-button')).toBeVisible(); + expect(within(listItem).getByRole('link')).toHaveAttribute( + 'href', + 'https://example.com.com/tokbox.com.archive2.eu/46969164/c32509e3-24a9-4d1f-98a0-66a0f0fdbca6/archive.mp4' + ); + }); + + it('should render an error icon for failed archives', () => { + render(); + const listItem = screen.getByTestId(`archive-list-item-${failedArchive.id}`); + expect(within(listItem).getByTestId('archive-error-icon')).toBeVisible(); + }); + + it('should render a spinner for pending archives', () => { + render(); + const listItem = screen.getByTestId(`archive-list-item-${pendingArchive.id}`); + expect(within(listItem).getByTestId('archive-loading-spinner')).toBeVisible(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx new file mode 100644 index 00000000..caa7b911 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx @@ -0,0 +1,194 @@ +import { Fragment, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Archive, ArchiveStatus } from '../../../api/archiving/model'; + +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import Separator from '@components/Separator'; +import VividIcon from '@ui/VividIcon'; +import formatDuration from '@utils/formatDuration'; +import formatFileSize from '@utils/formatFileSize'; +import classNames from 'classnames'; +import { isString } from '@common/assertions'; + +const ArchiveErrorIcon = () => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +const ArchivingLoadingIcon = () => { + return ( + + ); +}; + +const ArchiveStatusContent = ({ status, url }: { status: ArchiveStatus; url: string | null }) => { + const { t } = useTranslation(); + + if (status === 'available') { + return ( + +
        + + + + + {t('archiveList.download')} + +
        + + ); + } + + if (status === 'pending') { + return ; + } + + return ; +}; + +export type ArchiveListProps = { + archives: Archive[] | 'error'; +}; + +/** + * ArchiveList + * + * This component displays any archives. + * @param {ArchiveListProps} props - The props for the component. + * @property {Archive[] | 'error'} archives - Array of archives, or 'error'. + * @returns {ReactElement} - The ArchiveList component. + */ +const ArchiveList = ({ archives }: ArchiveListProps): ReactElement => { + const { t } = useTranslation(); + + const isError = isString(archives); + if (isError) { + return ( + + + + {t('archiveList.error.text')} + + + ); + } + if (!archives.length) { + return ( + <> +
        + + + {t('archiveList.empty')} + +
        + + + ); + } + return ( + + + {archives.map((archive, index) => ( + + + + + + + + + {archive.status === 'pending' + ? t('archiveList.loading') + : t('archiveList.archive.index', { + index: archives.length - index, + })} + + } + secondary={ + (archive.status === 'available' || archive.status === 'pending') && ( + + {archive.status === 'pending' ? ( + t('archiveList.loading.subtitle') + ) : ( + <> + {archive.duration && formatDuration(archive.duration)} + {archive.size && ` • ${formatFileSize(archive.size)}`} + {` • ${t('archiveList.archive.createdAt', { + createdAt: archive.createdAtFormatted, + })}`} + + )} + + ) + } + /> + + + + + + + + + + ))} + + + ); +}; + +export default ArchiveList; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/index.tsx new file mode 100644 index 00000000..c43f01bd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ArchiveList/index.tsx @@ -0,0 +1,3 @@ +import ArchiveList from './ArchiveList'; + +export default ArchiveList; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.spec.tsx new file mode 100644 index 00000000..86339336 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import MemoryRouter from '@test/RouterWrapper'; +import GoToLandingPageButton from './index'; + +describe('GoToLandingPageButton', () => { + it('should display the correct go to landing page button', async () => { + render( + + + + ); + + const button = screen.getByTestId('go-to-landing-button'); + await userEvent.click(button); + + expect(screen.getByText('View Landing Page')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx new file mode 100644 index 00000000..c81bc273 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx @@ -0,0 +1,26 @@ +import Button from '@mui/material/Button'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +/** + * GoToLandingPageButton Component + * + * This component returns a button that takes a user back to the landing page + * @returns {ReactElement} - the button to go back to the landing page. + */ +const GoToLandingPageButton = (): ReactElement => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const handleLanding = () => { + navigate('/'); + }; + + return ( + + ); +}; + +export default GoToLandingPageButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/index.tsx new file mode 100644 index 00000000..8eab9ad1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoToLandingPageButton/index.tsx @@ -0,0 +1,3 @@ +import GoToLandingPageButton from './GoToLandingPageButton'; + +export default GoToLandingPageButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.spec.tsx new file mode 100644 index 00000000..e2fe9381 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import { useNavigate } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import { describe, expect, it, Mock, vi, beforeEach, afterAll } from 'vitest'; +import GoodByeMessage from './GoodbyeMessage'; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { ...mod, useNavigate: vi.fn() }; +}); + +const mockNavigate = vi.fn(); +const headerMessage = 'This is a header message'; +const goodbyeMessage = 'This is a goodbye message'; + +describe('GoodbyeMessage', () => { + const originalMatchMedia = globalThis.matchMedia; + + beforeEach(() => { + (useNavigate as Mock).mockReturnValue(mockNavigate); + }); + + afterAll(() => { + globalThis.matchMedia = originalMatchMedia; + }); + + it('renders the header', () => { + render( + + + + ); + const header = screen.getByTestId('header-message'); + expect(header.textContent).toBe(headerMessage); + }); + + it('renders the goodbye message', () => { + render( + + + + ); + const goodbye = screen.getByTestId('goodbye-message'); + expect(goodbye.textContent).toBe(goodbyeMessage); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.tsx new file mode 100644 index 00000000..a3580080 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/GoodbyeMessage.tsx @@ -0,0 +1,54 @@ +import { ReactElement } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +export type GoodByeMessageProps = { + header: string; + message: string; +}; + +/** + * GoodByeMessage Component + * Displays a goodbye message to the user along with two navigation buttons - one to the previously left room, and another for the landing page. + * @param {GoodByeMessageProps} props - The props for the component. + * @returns {ReactElement} The GoodByeMessage component. + */ +const GoodByeMessage = ({ header, message }: GoodByeMessageProps): ReactElement => { + return ( + + + {header} + + + {message} + + + ); +}; + +export default GoodByeMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/index.tsx new file mode 100644 index 00000000..fe74d7d4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/GoodbyeMessage/index.tsx @@ -0,0 +1,3 @@ +import GoodByeMessage from './GoodbyeMessage'; + +export default GoodByeMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.spec.tsx new file mode 100644 index 00000000..04ae3970 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import MemoryRouter from '@test/RouterWrapper'; +import ReenterRoomButton from './index'; +import { AnyFunction } from '@common/types'; + +const mockUseParams = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => mockUseParams() as AnyFunction, + }; +}); + +describe('ReenterRoomButton', () => { + it('should display the correct reenter room button', async () => { + mockUseParams.mockReturnValue({ roomIdentifier: 'test-room' }); + + render( + + + + ); + + const button = screen.getByTestId('reenterButton'); + await userEvent.click(button); + + expect(screen.getByText('Go back to meeting')).toBeInTheDocument(); + }); + + it('should not display the reenter room button', () => { + mockUseParams.mockReturnValue({}); + + render( + + + + ); + + expect(screen.queryByTestId('reenterButton')).not.toBeInTheDocument(); + expect(screen.queryByText('Go back to meeting')).not.toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx new file mode 100644 index 00000000..d98c6040 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx @@ -0,0 +1,43 @@ +import VividIcon from '@ui/VividIcon'; +import useSessionKeyParam from '@hooks/useSessionKeyParam'; +import Button from '@mui/material/Button'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +/** + * ReenterRoomButton Component + * + * This component returns a button that takes a user back to the meeting. + * @returns {ReactElement} - the re-enter room button or an empty string if the room does not exist. + */ +const ReenterRoomButton = (): ReactElement | string => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { sessionKey } = useSessionKeyParam(); + + const handleReenter = () => { + navigate(`/waiting-room/${sessionKey}`); + }; + return ( + sessionKey && ( + + ) + ); +}; + +export default ReenterRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/index.tsx new file mode 100644 index 00000000..a8517c6e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/GoodBye/ReenterRoomButton/index.tsx @@ -0,0 +1,3 @@ +import ReenterRoomButton from './ReenterRoomButton'; + +export default ReenterRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx new file mode 100644 index 00000000..039febb3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.spec.tsx @@ -0,0 +1,140 @@ +import { ReactElement } from 'react'; +import { fireEvent, render as renderBase } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import { SubscriberWrapper } from '@app-types/session'; +import HiddenParticipantsTile from './index'; +import { env } from '../../env'; + +describe('HiddenParticipantsTile', () => { + const box = { height: 100, width: 100, top: 0, left: 0 }; + const hiddenSubscribers = [ + { + element: document.createElement('video'), + subscriber: { + id: '1', + stream: { + name: 'John Doe', + streamId: '1', + initials: 'JD', + } as Partial, + getAudioVolume: vi.fn(() => 1), + getImgData: vi.fn(() => null), + getStats: vi.fn(), + getRtcStatsReport: vi.fn(() => Promise.resolve(new Map())), + } as Partial, + isScreenshare: false, + id: '1', + isPinned: false, + }, + ]; + + it('should display two hidden participants', async () => { + const currentHiddenSubscribers = [ + ...hiddenSubscribers, + { + element: document.createElement('video'), + subscriber: { + id: '2', + stream: { + name: 'Jane Smith', + streamId: '2', + initials: 'JS', + } as Partial, + getAudioVolume: vi.fn(() => 1), + getImgData: vi.fn(() => null), + getStats: vi.fn(), + getRtcStatsReport: vi.fn(() => Promise.resolve(new Map())), + } as Partial, + isScreenshare: false, + id: '2', + isPinned: false, + }, + ] as SubscriberWrapper[]; + + const { sessionContext, getByTestId, getByText } = render( + + ); + + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); + + const button = getByTestId('hidden-participants'); + expect(button).toBeInTheDocument(); + await userEvent.click(button); + + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + + expect(getByText('JD')).toBeInTheDocument(); + expect(getByText('JS')).toBeInTheDocument(); + + expect(toggleParticipantListSpy).toHaveBeenCalled(); + }); + + it('should display one hidden participant because the other one is empty', async () => { + const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[]; + + const { sessionContext, getByText, getByTestId, queryByText } = render( + + ); + + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); + + const button = getByTestId('hidden-participants'); + expect(button).toBeInTheDocument(); + await userEvent.click(button); + + expect(getByText('JD')).toBeInTheDocument(); + expect(queryByText('JS')).not.toBeInTheDocument(); + + expect(toggleParticipantListSpy).toHaveBeenCalled(); + }); + + it('does not toggle participant list when showParticipantList is disabled', async () => { + const currentHiddenSubscribers = [...hiddenSubscribers, {}] as SubscriberWrapper[]; + + env.partialUpdate({ + SHOW_PARTICIPANT_LIST: false, + }); + + const { sessionContext, getByTestId } = render( + + ); + + const toggleParticipantListSpy = vi.mocked(sessionContext.current.toggleParticipantList); + + const button = getByTestId('hidden-participants'); + expect(button).toBeInTheDocument(); + await userEvent.click(button); + + expect(toggleParticipantListSpy).not.toHaveBeenCalled(); + }); +}); + +type RenderOptions = { + sessionContext?: ProviderOptions['SessionContext']; + userContext?: ProviderOptions['UserContext']; +}; + +function render(ui: ReactElement, { sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext: { + __onCreated: (ctx) => { + vi.spyOn(ctx, 'toggleParticipantList'); + }, + ...sessionContext, + }, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx new file mode 100644 index 00000000..eb4032b7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/HiddenParticipantsTile.tsx @@ -0,0 +1,78 @@ +import { ReactElement } from 'react'; +import classNames from 'classnames'; +import { Box } from 'opentok-layout-js'; +import { SubscriberWrapper } from '@app-types/session'; +import getBoxStyle from '@utils/helpers/getBoxStyle'; +import useSessionContext from '@hooks/useSessionContext'; +import AvatarInitials from '../AvatarInitials'; +import AvatarGroup from '@mui/material/AvatarGroup'; +import ButtonBase from '@mui/material/ButtonBase'; +import { env } from '../../env'; + +export type HiddenParticipantsTileProps = { + box: Box; + hiddenSubscribers: SubscriberWrapper[]; +}; +/** + * HiddenParticipantsTile Component + * A clickable tile matching VideoTile style to represent the Subscribers that are hidden from the call. + * Will show the initial Avatars of the first two hidden subscribers + * @param {HiddenParticipantsTileProps} props - the props for this component + * @property {Box} box - Box specifying position and size of tile + * @property {() => void} handleClick - click handler, e.g. open participant list + * @property {SubscriberWrapper[]} hiddenSubscribers - Array of hidden subscribers + * @returns {ReactElement} HiddenParticipantsTile instance + */ +const HiddenParticipantsTile = ({ + box, + hiddenSubscribers, +}: HiddenParticipantsTileProps): ReactElement => { + const { toggleParticipantList } = useSessionContext(); + + const { height, width } = box; + const diameter = Math.min(height, width) * 0.38; + return ( + {}} + type="button" + > + + {hiddenSubscribers.slice(0, 2).map((wrapper) => { + const { initials, streamId, name } = wrapper?.subscriber?.stream ?? {}; + const sx = { position: 'relative' }; + + return ; + })} + + + ); +}; + +export default HiddenParticipantsTile; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/index.tsx new file mode 100644 index 00000000..968923b8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/HiddenParticipantsTile/index.tsx @@ -0,0 +1,3 @@ +import HiddenParticipantsTile from './HiddenParticipantsTile'; + +export default HiddenParticipantsTile; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.spec.tsx new file mode 100644 index 00000000..a7d88a73 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.spec.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import PushPinOffIcon from './index'; + +describe('PushPinOffIcon', () => { + it('should display the correct PushPinOffIcon', () => { + render(); + + expect(screen.queryByTestId('PushPinOffIcon')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.tsx new file mode 100644 index 00000000..3a535c3c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/PushPinOffIcon.tsx @@ -0,0 +1,29 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import SvgIcon from '@mui/material/SvgIcon'; +import { ReactElement } from 'react'; + +/** + * PushPinOffIcon Component + * + * This component renders a custom SVG icon representing a Push pin with a diagonal line across it. + * This follows the MUI 'Off' icon visual and naming convention. + * @param {SvgIconProps} props - the props to customize the SVG icon. + * @returns {ReactElement} - the blur icon. + */ +const PushPinOffIcon = (props: SvgIconProps): ReactElement => { + return ( + + + + + + ); +}; + +export default PushPinOffIcon; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/index.tsx new file mode 100644 index 00000000..4c06ee1c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Icons/PushPinOffIcon/index.tsx @@ -0,0 +1,3 @@ +import PushPinOffIcon from './PushPinOffIcon'; + +export default PushPinOffIcon; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.spec.tsx new file mode 100644 index 00000000..9dfe607a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.spec.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import JoinContainerSeparator from './JoinContainerSeparator'; + +describe('JoinContainerSeparator', () => { + it('renders the component with separators and text and applies correct styling', () => { + render(); + + expect(screen.getByText('or')).toBeInTheDocument(); + + const separators = screen.getAllByTestId('separator'); + expect(separators).toHaveLength(2); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx new file mode 100644 index 00000000..ca83c6c3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import Separator from '../Separator'; + +/** + * JoinContainerSeparator Component + * + * Component used as a visual separator between two UI elements. + * @returns {ReactElement} The JoinContainerSeparator component. + */ +const JoinContainerSeparator = (): ReactElement => { + const { t } = useTranslation(); + + return ( +
        + + + {t('common.or')} + + +
        + ); +}; + +export default JoinContainerSeparator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/index.tsx new file mode 100644 index 00000000..23922fcc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinContainerSeparator/index.tsx @@ -0,0 +1,3 @@ +import JoinContainerSeparator from './JoinContainerSeparator'; + +export default JoinContainerSeparator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.spec.tsx new file mode 100644 index 00000000..98a06c42 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.spec.tsx @@ -0,0 +1,23 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { makeTestProvider, providers } from '@test/providers'; +import JoinExistingRoom from './index'; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useNavigate: vi.fn(), + }; +}); + +describe('JoinExistingRoom', () => { + it('should display the correct JoinExistingRoom', () => { + const { wrapper } = makeTestProvider([providers.runtime]); + renderBase(, { wrapper }); + + expect(screen.getByTestId('JoinExistingRoom')).toBeInTheDocument(); + expect(screen.getByText('Join waiting room')).toBeInTheDocument(); + expect(screen.getAllByText(/Room name/i)[0]).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.tsx new file mode 100644 index 00000000..17a7fcfc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/JoinExistingRoom.tsx @@ -0,0 +1,29 @@ +import { ReactElement, useState } from 'react'; +import JoinWaitRoomButton from '../JoinWaitRoomButton'; +import RoomNameInput from '../RoomNameInput'; + +/** + * JoinExistingRoom Component + * + * Displays a text box and button to join the waiting room for a custom room. + * @returns {ReactElement} - The JoinExistingRoom component. + */ +const JoinExistingRoom = (): ReactElement => { + const [roomName, setRoomName] = useState(''); + const [hasError, setHasError] = useState(false); + + return ( +
        + + + +
        + ); +}; + +export default JoinExistingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/index.tsx new file mode 100644 index 00000000..4d0c9d3a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinExistingRoom/index.tsx @@ -0,0 +1,3 @@ +import JoinExistingRoom from './JoinExistingRoom'; + +export default JoinExistingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.spec.tsx new file mode 100644 index 00000000..db1b8df2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.spec.tsx @@ -0,0 +1,67 @@ +import { render as renderBase, screen, fireEvent, waitFor } from '@testing-library/react'; +import { useNavigate } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import { describe, expect, it, Mock, vi, beforeEach } from 'vitest'; +import { makeTestProvider, providers } from '@test/providers'; +import type { VideoClient } from '@core/services'; +import JoinWaitRoomButton from './JoinWaitRoomButton'; + +const mockMutate = vi.fn(); + +const mockVideoClient = { + createSession: (...args: unknown[]): unknown => mockMutate(...args), +} as unknown as VideoClient; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useNavigate: vi.fn(), + }; +}); + +const mockNavigate = vi.fn(); +const mockSetHasError = vi.fn(); + +describe('JoinWaitRoomButtonComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMutate.mockResolvedValue({ sessionKey: 'resolved-session-key' }); + }); + + it('should navigate to the waiting room if the room name is valid', async () => { + (useNavigate as Mock).mockReturnValue(mockNavigate); + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/waiting-room/resolved-session-key'); + }); + }); + + it('should not navigate and set error if the room name is empty', () => { + (useNavigate as Mock).mockReturnValue(mockNavigate); + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSetHasError).toHaveBeenCalledWith(true); + }); +}); + +function render(ui: React.ReactElement) { + const { wrapper } = makeTestProvider([providers.runtime], { + runtimeContext: { videoClient: mockVideoClient }, + }); + return renderBase(ui, { wrapper }); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.tsx new file mode 100644 index 00000000..031f40a3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/JoinWaitRoomButton.tsx @@ -0,0 +1,55 @@ +import Button from '@mui/material/Button'; +import { Dispatch, MouseEvent, ReactElement, SetStateAction } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { runtime$ } from '@core/stores'; + +export type JoinWaitRoomButtonProps = { + roomName: string; + isDisabled: boolean; + setHasError: Dispatch>; +}; + +/** + * JoinWaitRoomButton Component + * + * This component returns a button that takes a user to the meeting. + * @param {JoinWaitRoomButtonProps} props - the props for this component. + * @property {string} roomName - the name of the room to join. + * @property {boolean} isDisabled - whether the button is disabled. + * @property {Dispatch>} setHasError - function to set the error state. + * @returns {ReactElement} - the join room button. + */ +const JoinWaitRoomButton = ({ + roomName, + isDisabled, + setHasError, +}: JoinWaitRoomButtonProps): ReactElement => { + const videoClient = runtime$.useVideoClient(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleJoin = async (event: MouseEvent) => { + event.preventDefault(); + if (roomName === '') { + setHasError(true); + return; + } + + /** + * [TODO] If the session already exists returns the session key, + * Right now the createSession endpoint has a patch to support the legacy roomName functionality + */ + const session = await videoClient.createSession({ roomName }); + + navigate(`/waiting-room/${session.sessionKey}`); + }; + + return ( + + ); +}; + +export default JoinWaitRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/index.tsx new file mode 100644 index 00000000..c78bc852 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/JoinWaitRoomButton/index.tsx @@ -0,0 +1,3 @@ +import JoinWaitRoomButton from './JoinWaitRoomButton'; + +export default JoinWaitRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.spec.tsx new file mode 100644 index 00000000..0495fcb0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.spec.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import LandingPageWelcome from './LandingPageWelcome'; + +describe('LandingPageWelcome', () => { + it('renders the welcome heading and applies correct styling', () => { + render(); + + const textHeading = screen.getByText( + 'Power your business with video that transforms customer satisfaction.' + ); + expect(textHeading).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx new file mode 100644 index 00000000..b0adcb98 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx @@ -0,0 +1,79 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +/** + * LandingPageWelcome Component + * This component includes a welcome message to the users visiting the landing page. + * @returns {ReactElement} - the landing page component + */ +const LandingPageWelcome = (): ReactElement => { + const { t } = useTranslation(); + const primaryWord = t('landing.primary.word'); + + const renderTitle = (titleKey: string) => { + const text = t(titleKey); + const isPrimaryWord = text.toLowerCase().includes(primaryWord.toLowerCase()); + + return ( + + {text} + + ); + }; + + return ( + + + <> + {renderTitle('landing.welcome.title.1')} + {renderTitle('landing.welcome.title.2')} + {renderTitle('landing.welcome.title.3')} + + + + + {t('landing.welcome.subtitle')} + + + ); +}; + +export default LandingPageWelcome; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/index.tsx new file mode 100644 index 00000000..5dd4ac15 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LandingPageWelcome/index.tsx @@ -0,0 +1,3 @@ +import LandingPageWelcome from './LandingPageWelcome'; + +export default LandingPageWelcome; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx new file mode 100644 index 00000000..cb59d749 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.spec.tsx @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import LanguageSelector from './LanguageSelector'; +import { env } from '../../env'; + +// Mock VividIcon component +vi.mock('@ui/VividIcon', () => ({ + default: ({ name, customSize }: { name: string; customSize: number }) => ( +
        + {name} +
        + ), +})); + +// Mock react-i18next +const mockChangeLanguage = vi.fn(); +const mockT = vi.fn((key: string) => { + const translations: Record = { + 'languages.english': 'English', + 'languages.german': 'Deutsch', + 'languages.spanish': 'Español', + 'languages.spanishMX': 'Español (México)', + 'languages.italian': 'Italiano', + }; + return translations[key] || key; +}); + +const mockI18n: { + language: string | undefined | null; + changeLanguage: typeof mockChangeLanguage; + options: { + fallbackLng: string; + }; +} = { + language: 'en', + changeLanguage: mockChangeLanguage, + options: { + fallbackLng: 'en', + }, +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: mockT, + i18n: mockI18n, + }), +})); + +describe('LanguageSelector', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockI18n.language = 'en'; + }); + + describe('Rendering', () => { + it('renders with default props', () => { + env.setSupportedLanguages('en|es|it|de'); + + render(); + + expect(screen.getByTestId('language-selector')).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-united-kingdom')).toBeInTheDocument(); + }); + + it('renders without flags when showFlag is false', () => { + env.setSupportedLanguages('en|es'); + + render(); + + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.queryByTestId('vivid-icon-flag-united-kingdom')).not.toBeInTheDocument(); + }); + + it('renders VividIcon with correct size in main display', () => { + env.setSupportedLanguages('en'); + + render(); + + const icon = screen.getByTestId('vivid-icon-flag-united-kingdom'); + expect(icon).toHaveAttribute('data-size', '-2'); + }); + }); + + describe('Supported Languages', () => { + it('shows only supported languages from environment variable', async () => { + env.setSupportedLanguages('en|es|de'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-es')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-de')).toBeInTheDocument(); + expect(screen.queryByTestId('language-option-it')).not.toBeInTheDocument(); + expect(screen.queryByTestId('language-option-es-MX')).not.toBeInTheDocument(); + }); + }); + + it('shows all languages when all are supported', async () => { + env.setSupportedLanguages('en|es|es-MX|it|en-US|de'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-es')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-es-MX')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-it')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-en-US')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-de')).toBeInTheDocument(); + }); + }); + + it('falls back to en when no supported languages env var', async () => { + env.setSupportedLanguages(''); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en')).toBeInTheDocument(); + expect(screen.queryByTestId('language-option-es')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Language Selection', () => { + it('changes language when option is selected', async () => { + env.setSupportedLanguages('en|es|it'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-es')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('language-option-es')); + + expect(mockChangeLanguage).toHaveBeenCalledWith('es'); + }); + + it('changes language to es-MX when selected', async () => { + env.setSupportedLanguages('en|es-MX'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-es-MX')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('language-option-es-MX')); + + expect(mockChangeLanguage).toHaveBeenCalledWith('es-MX'); + }); + + it('changes language to en-US when selected', async () => { + env.setSupportedLanguages('en|en-US'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en-US')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('language-option-en-US')); + + expect(mockChangeLanguage).toHaveBeenCalledWith('en-US'); + }); + }); + + describe('Current Language Display', () => { + it('displays current language correctly with flag icon', () => { + env.setSupportedLanguages('en|es|it'); + mockI18n.language = 'es'; + + render(); + + expect(screen.getByText('Español')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-spain')).toBeInTheDocument(); + }); + + it('displays Italian language correctly', () => { + env.setSupportedLanguages('en|es|it'); + mockI18n.language = 'it'; + + render(); + + expect(screen.getByText('Italiano')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-italy')).toBeInTheDocument(); + }); + + it('displays Mexican Spanish correctly', () => { + env.setSupportedLanguages('en|es-MX'); + mockI18n.language = 'es-MX'; + + render(); + + expect(screen.getByText('Español (México)')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-mexico')).toBeInTheDocument(); + }); + + it('displays US English correctly', () => { + env.setSupportedLanguages('en|en-US'); + mockI18n.language = 'en-US'; + + render(); + + expect(screen.getByText('English (US)')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-united-states')).toBeInTheDocument(); + }); + + it('displays German correctly', () => { + env.setSupportedLanguages('en|de'); + mockI18n.language = 'de'; + + render(); + + expect(screen.getByText('Deutsch')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-germany')).toBeInTheDocument(); + }); + + it('handles unsupported language gracefully', () => { + env.setSupportedLanguages('en|es'); + mockI18n.language = 'fr'; + + render(); + + expect(screen.getByDisplayValue('fr')).toBeInTheDocument(); + }); + }); + + describe('Fallback Language Handling', () => { + it('uses en as fallback when current language is empty', () => { + env.setSupportedLanguages('en|es'); + mockI18n.language = ''; + + render(); + + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-united-kingdom')).toBeInTheDocument(); + }); + + it('uses en as fallback when current language is undefined', () => { + env.setSupportedLanguages('en|es'); + mockI18n.language = undefined; + + render(); + + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-united-kingdom')).toBeInTheDocument(); + }); + + it('uses en as fallback when current language is null', () => { + env.setSupportedLanguages('en|es'); + mockI18n.language = null; + + render(); + + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByTestId('vivid-icon-flag-united-kingdom')).toBeInTheDocument(); + }); + }); + + describe('Dropdown Behavior', () => { + it('shows language options when opened', async () => { + env.setSupportedLanguages('en|es|it'); + + render(); + + expect(screen.queryByTestId('language-option-es')).not.toBeInTheDocument(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-es')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-it')).toBeInTheDocument(); + }); + }); + + it('displays language names and flag icons in options', async () => { + env.setSupportedLanguages('en|es'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + const englishOption = screen.getByTestId('language-option-en'); + expect(englishOption).toHaveTextContent('English'); + + const spanishOption = screen.getByTestId('language-option-es'); + expect(spanishOption).toHaveTextContent('Español'); + + const englishIcon = screen + .getAllByTestId('vivid-icon-flag-united-kingdom') + .find((icon) => icon.getAttribute('data-size') === '-2'); + const spanishIcon = screen + .getAllByTestId('vivid-icon-flag-spain') + .find((icon) => icon.getAttribute('data-size') === '-2'); + + expect(englishIcon).toBeInTheDocument(); + expect(spanishIcon).toBeInTheDocument(); + }); + }); + + it('hides flags in options when showFlag is false', async () => { + env.setSupportedLanguages('en|es'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + const englishOption = screen.getByTestId('language-option-en'); + expect(englishOption).toHaveTextContent('English'); + expect(screen.queryByTestId('vivid-icon-flag-united-kingdom')).not.toBeInTheDocument(); + expect(screen.queryByTestId('vivid-icon-flag-spain')).not.toBeInTheDocument(); + }); + }); + + it('renders VividIcon with correct size in dropdown options', async () => { + env.setSupportedLanguages('en|es'); + + render(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + const dropdownIcon = screen + .getAllByTestId('vivid-icon-flag-united-kingdom') + .find((icon) => icon.getAttribute('data-size') === '-2'); + expect(dropdownIcon).toBeInTheDocument(); + }); + }); + }); + + describe('VividIcon Integration', () => { + it('uses different sizes for display vs dropdown', async () => { + env.setSupportedLanguages('en'); + + render(); + + const displayIcon = screen.getByTestId('vivid-icon-flag-united-kingdom'); + expect(displayIcon).toHaveAttribute('data-size', '-2'); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + const dropdownIcons = screen.getAllByTestId('vivid-icon-flag-united-kingdom'); + const dropdownIcon = dropdownIcons.find((icon) => icon.getAttribute('data-size') === '-2'); + expect(dropdownIcon).toBeInTheDocument(); + }); + }); + }); + + describe('Accessibility', () => { + it('has proper test ids for testing', async () => { + env.setSupportedLanguages('en|es'); + + render(); + + expect(screen.getByTestId('language-selector')).toBeInTheDocument(); + + const selectButton = screen.getByRole('combobox'); + fireEvent.mouseDown(selectButton); + + await waitFor(() => { + expect(screen.getByTestId('language-option-en')).toBeInTheDocument(); + expect(screen.getByTestId('language-option-es')).toBeInTheDocument(); + }); + }); + + it('maintains MUI Select accessibility features', () => { + env.setSupportedLanguages('en'); + + render(); + + const selectButton = screen.getByRole('combobox'); + expect(selectButton).toBeInTheDocument(); + expect(selectButton).toHaveAttribute('aria-haspopup', 'listbox'); + }); + + it('VividIcon components have proper test ids', () => { + env.setSupportedLanguages('en'); + + render(); + + expect(screen.getByTestId('vivid-icon-flag-united-kingdom')).toBeInTheDocument(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.tsx new file mode 100644 index 00000000..795ab99a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.tsx @@ -0,0 +1,103 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import Select from '@mui/material/Select'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import { LanguageOption, LanguageSelectorProps } from './LanguageSelector.types'; +import useIsSmallViewport from '../../hooks/useIsSmallViewport'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../env'; + +const languageOptions: LanguageOption[] = [ + { code: 'en', name: 'English', flag: 'flag-united-kingdom' }, + { code: 'en-US', name: 'English (US)', flag: 'flag-united-states' }, + { code: 'de', name: 'Deutsch', flag: 'flag-germany' }, + { code: 'it', name: 'Italiano', flag: 'flag-italy' }, + { code: 'es', name: 'Español', flag: 'flag-spain' }, + { code: 'es-MX', name: 'Español (México)', flag: 'flag-mexico' }, +]; + +const ChevronIcon = ({ className, ...props }: { className?: string } & Record) => ( + + + +); + +const SelectIconComponent = + (_iconClassName: string) => + (props: Record): ReactElement => ; + +/** + * LanguageSelector Component + * A dropdown component that allows users to select their preferred language. + * The available languages are determined by the I18N_SUPPORTED_LANGUAGES environment variable. + * @param {LanguageSelectorProps} props - The props for the component. + * @property {boolean} showFlag - Whether to display the country flag alongside the language name. + * @returns {ReactElement} The rendered LanguageSelector component. + */ +const LanguageSelector = ({ showFlag = true }: LanguageSelectorProps): ReactElement => { + const { i18n } = useTranslation(); + const isSmallViewport = useIsSmallViewport(); + + const supportedLanguages = languageOptions.filter((option) => + env.I18N_SUPPORTED_LANGUAGES.includes(option.code) + ); + + const handleLanguageChange = (event: SelectChangeEvent) => { + const newLanguage = event.target.value; + void i18n.changeLanguage(newLanguage); + }; + + const currentLanguage = i18n.language || 'en'; + + return ( + + + + ); +}; + +export default LanguageSelector; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.types.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.types.tsx new file mode 100644 index 00000000..05469425 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/LanguageSelector.types.tsx @@ -0,0 +1,11 @@ +import { Lang } from '../../env'; + +export type LanguageOption = { + code: Lang; + name: string; + flag: string; +}; + +export type LanguageSelectorProps = { + showFlag?: boolean; +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/index.tsx new file mode 100644 index 00000000..9e525056 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/LanguageSelector/index.tsx @@ -0,0 +1,3 @@ +import LanguageSelector from './LanguageSelector'; + +export default LanguageSelector; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.spec.tsx new file mode 100644 index 00000000..4685f251 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.spec.tsx @@ -0,0 +1,26 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +describe('AdvancedSettingsButton', () => { + it('renders the toolbar button', () => { + render(); + + expect(screen.getByTestId('advanced-settings-button')).toBeInTheDocument(); + }); + + it('toggles the button tooltip state through context', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('advanced-settings-button')); + + expect(screen.getByTestId('advanced-settings-button')).toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + return renderBase(ui); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.tsx new file mode 100644 index 00000000..52a395fc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/AdvancedSettingsButton.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import Tooltip from '@mui/material/Tooltip'; +import { useTranslation } from 'react-i18next'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import VividIcon from '@ui/VividIcon'; +import ToolbarButton from '../ToolbarButton'; + +export type AdvancedSettingsButtonProps = { + isOverflowButton?: boolean; +}; + +const AdvancedSettingsButton = ({ + isOverflowButton = false, +}: AdvancedSettingsButtonProps): ReactElement => { + const { t } = useTranslation(); + const [isOpen, { open, close }] = advancedSettings$.use(({ isOpen }) => isOpen); + + const handleClick = () => { + if (isOpen) { + close(); + return; + } + + open(); + }; + + return ( + + + } + isOverflowButton={isOverflowButton} + /> + + ); +}; + +export default AdvancedSettingsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/index.tsx new file mode 100644 index 00000000..4a8f551b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AdvancedSettingsButton/index.tsx @@ -0,0 +1,3 @@ +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +export default AdvancedSettingsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx new file mode 100644 index 00000000..8e61271d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.spec.tsx @@ -0,0 +1,134 @@ +import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; +import { render as renderBase, screen, act } from '@testing-library/react'; +import { ReactElement } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import { makeTestProvider, providers } from '@test/providers'; +import ArchivingButton from './ArchivingButton'; +import { env } from '../../../env'; +import type { VideoClient } from '@core/services'; + +vi.mock('@hooks/useSessionContext'); + +const mockVideoClient: VideoClient = { + startArchive: vi.fn(), + stopArchive: vi.fn(), +} as unknown as VideoClient; + +describe('ArchivingButton', () => { + const mockHandleCloseMenu = vi.fn(); + const mockedSessionKey = 'test-session-key'; + let sessionContext: SessionContextType; + + const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; + const testArchiveId = 'test-archive-id'; + + beforeEach(() => { + vi.clearAllMocks(); + sessionContext = { + subscriberWrappers: [], + archiveId: null, + markArchiveStartRequestedBySelf: vi.fn(), + resetArchiveStartRequestedBySelf: vi.fn(), + sessionKey: mockedSessionKey, + connected: true, + } as unknown as SessionContextType; + + mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); + }); + + it('renders the button correctly', () => { + render(); + expect(screen.getByTestId('archiving-button')).toBeInTheDocument(); + }); + + it('opens the modal when the button is clicked', () => { + render(); + act(() => screen.getByTestId('archiving-button').click()); + expect(screen.getByText('Start Recording?')).toBeInTheDocument(); + }); + + it('triggers the start archiving when button is pressed', async () => { + vi.useFakeTimers(); + (mockVideoClient.startArchive as Mock).mockResolvedValue({ data: { success: true } }); + render(); + + act(() => screen.getByTestId('archiving-button').click()); + expect(screen.getByText('Start Recording?')).toBeInTheDocument(); + + // click the button to start archiving + act(() => screen.getByTestId('popup-dialog-primary-button').click()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mockVideoClient.startArchive).toHaveBeenCalledWith({ sessionKey: mockedSessionKey }); + + vi.useRealTimers(); + }); + + it('shows stop recording dialog when archiving is active', () => { + mockUseSessionContext.mockReturnValue({ + subscriberWrappers: [], + archiveId: 'test-archive-id', + markArchiveStartRequestedBySelf: vi.fn(), + resetArchiveStartRequestedBySelf: vi.fn(), + } as unknown as SessionContextType); + + render(); + act(() => screen.getByTestId('archiving-button').click()); + expect(screen.getByText('Stop Recording?')).toBeInTheDocument(); + }); + + it('triggers stop archiving when recording is active', async () => { + vi.useFakeTimers(); + mockUseSessionContext.mockReturnValue({ + subscriberWrappers: [], + archiveId: testArchiveId, + markArchiveStartRequestedBySelf: vi.fn(), + resetArchiveStartRequestedBySelf: vi.fn(), + sessionKey: mockedSessionKey, + connected: true, + } as unknown as SessionContextType); + + render(); + + act(() => screen.getByTestId('archiving-button').click()); + expect(screen.getByText('Stop Recording?')).toBeInTheDocument(); + + // click the button to stop archiving + act(() => screen.getByTestId('popup-dialog-primary-button').click()); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mockVideoClient.stopArchive).toHaveBeenCalledWith({ + sessionKey: mockedSessionKey, + archiveId: testArchiveId, + }); + + vi.useRealTimers(); + }); + + it('is not rendered when allowArchiving is disabled', () => { + env.partialUpdate({ + ALLOW_ARCHIVING: false, + }); + render(); + + expect(screen.queryByTestId('archiving-button')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([providers.runtime], { + runtimeContext: { videoClient: mockVideoClient }, + }); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx new file mode 100644 index 00000000..8e3b896a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx @@ -0,0 +1,134 @@ +import { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { runtime$ } from '@core/stores'; +import useSessionContext from '@hooks/useSessionContext'; +import ToolbarButton from '../ToolbarButton'; +import PopupDialog, { DialogTexts } from '../PopupDialog'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import classNames from 'classnames'; +import { env } from '../../../env'; +import { RECORDING_START_DELAY } from '@utils/constants'; + +export type ArchivingButtonProps = { + isOverflowButton?: boolean; + handleClick?: () => void; +}; + +/** + * ArchivingButton Component + * + * Displays a button and handles the archiving functionality. If a meeting is currently being recorded, + * will confirm that a user wishes to stop the recording. If a meeting is not being recorded, prompts + * the user before starting the archive. + * @param {ArchivingButtonProps} props - the props for the component + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @property {(event?: MouseEvent | TouchEvent) => void} handleClick - (optional) click handler that closes the overflow menu in small viewports. + * @returns {ReactElement | false} - The ArchivingButton component. + */ +const ArchivingButton = ({ + isOverflowButton = false, + handleClick, +}: ArchivingButtonProps): ReactElement | false => { + const videoClient = runtime$.useVideoClient(); + const { t } = useTranslation(); + const { + archiveId, + markArchiveStartRequestedBySelf, + resetArchiveStartRequestedBySelf, + sessionKey, + connected, + } = useSessionContext(); + + const isRecording = !!archiveId; + const [isModalOpen, setIsModalOpen] = useState(false); + const title = isRecording ? t('recording.stop.title') : t('recording.start.title'); + const handleButtonClick = () => { + setIsModalOpen((prev) => !prev); + }; + + const startRecordingText: DialogTexts = { + title: t('recording.start.dialog.title'), + contents: t('recording.start.dialog.content'), + primaryActionText: t('recording.start.title'), + secondaryActionText: t('button.cancel'), + }; + + const stopRecordingText: DialogTexts = { + title: t('recording.stop.dialog.title'), + contents: t('recording.stop.dialog.content'), + primaryActionText: t('recording.stop.title'), + secondaryActionText: t('button.cancel'), + }; + + const actionText = isRecording ? stopRecordingText : startRecordingText; + + const handleClose = () => { + setIsModalOpen(false); + + // If the ArchivingButton is in the ToolbarOverflowMenu, we close the modal and the menu + if (isOverflowButton && handleClick) { + handleClick(); + } + }; + + const handleDialogClick = (action: 'start' | 'stop') => { + if (action === 'start') { + if (!archiveId && connected) { + markArchiveStartRequestedBySelf(); + setTimeout(async () => { + try { + await videoClient.startArchive({ sessionKey: sessionKey! }); + } catch (err) { + resetArchiveStartRequestedBySelf(); + console.log(err); + } + }, RECORDING_START_DELAY); + } + } else if (archiveId) { + void videoClient.stopArchive({ sessionKey: sessionKey!, archiveId }); + } + }; + + const handleActionClick = () => { + handleClose(); + void handleDialogClick(isRecording ? 'stop' : 'start'); + }; + + return ( + env.ALLOW_ARCHIVING && ( + <> + + + } + style={{ + marginTop: isOverflowButton ? '0px' : '4px', + backgroundColor: isRecording + ? 'color-mix(in srgb, var(--vera-on-secondary-light) 33%, transparent) !important' + : undefined, + }} + isOverflowButton={isOverflowButton} + /> + + + + ) + ); +}; +export default ArchivingButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/index.tsx new file mode 100644 index 00000000..43abe54c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ArchivingButton/index.tsx @@ -0,0 +1,3 @@ +import ArchivingButton from './ArchivingButton'; + +export default ArchivingButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.spec.tsx new file mode 100644 index 00000000..200bf099 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.spec.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render as renderBase, screen } from '@testing-library/react'; +import { Stream } from '@vonage/client-sdk-video'; +import AudioIndicator, { AudioIndicatorProps } from './AudioIndicator'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import { ReactElement } from 'react'; + +describe('AudioIndicator', () => { + const mockForceMute = vi.fn(); + const mockStream: Stream = { + connection: { connectionId: 'mock-connection-id', creationTime: Date.now(), data: 'mockData' }, + streamId: 'mock-stream-id', + creationTime: Date.now(), + hasAudio: true, + hasVideo: false, + name: 'John Doe', + videoDimensions: { width: 640, height: 480 }, + videoType: 'camera', + frameRate: 1, + initials: 'JD', + hasCaptions: false, + }; + + const defaultProps: AudioIndicatorProps = { + hasAudio: true, + stream: mockStream, + audioLevel: undefined, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders Mic icon when participant is unmuted but not speaking', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.forceMute = mockForceMute; + } + }, + }, + }); + const micIcon = screen.getByTestId('MicIcon'); + expect(micIcon).toBeInTheDocument(); + }); + + it('renders Mic off icon when participant is muted', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.forceMute = mockForceMute; + } + }, + }, + }); + const micOffIcon = screen.getByTestId('vivid-icon-mic-mute-solid'); + expect(micOffIcon).toBeInTheDocument(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render(ui: ReactElement, { userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + userContext, + sessionContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.tsx new file mode 100644 index 00000000..ba0793ac --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/AudioIndicator.tsx @@ -0,0 +1,120 @@ +import { ReactElement, useState } from 'react'; +import { Stream } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; +import PopupDialog, { DialogTexts } from '../PopupDialog'; +import VoiceIndicatorIcon from '../VoiceIndicator/VoiceIndicator'; +import useSessionContext from '../../../hooks/useSessionContext'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +export type AudioIndicatorProps = { + hasAudio: boolean | undefined; + indicatorStyle?: React.CSSProperties; + indicatorColor?: string; + indicatorClassName?: string; + stream?: Stream; + audioLevel?: number; + participantName?: string; +}; + +/** + * AudioIndicator Component + * + * This component displays an icon based on the audio status and handles muting another user. + * @param {AudioIndicatorProps} hasAudio - Indicates whether the user has audio enabled. + * @property {boolean | undefined} audioLevel - (optional) Indicates the current audio level of the video tile. + * @property {React.CSSProperties} indicatorStyle - (optional) The MUI sx styling props for the component. + * @property {string} indicatorColor - (optional) The color of the audio indicator button. + * @property {Stream} stream - (optional) the stream that can be used to force mute a user. + * @property {string} participantName - (optional) the name of the participant that can be muted. + * @returns {ReactElement} The audio indicator component. + */ +const AudioIndicator = ({ + hasAudio, + indicatorStyle, + indicatorColor, + indicatorClassName, + stream, + audioLevel, + participantName, +}: AudioIndicatorProps): ReactElement => { + const { t } = useTranslation(); + const { forceMute } = useSessionContext(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const muteParticipantText: DialogTexts = { + contents: t('participants.mute.dialog.content', { participantName }), + primaryActionText: t('button.mute'), + secondaryActionText: t('button.cancel'), + }; + const handleClick = () => { + setIsModalOpen(true); + }; + + const handleClose = () => { + setIsModalOpen(false); + }; + + const handleActionClick = () => { + if (forceMute && stream) { + void forceMute(stream); + handleClose(); + } + }; + + const styles = { + color: indicatorColor, + cursor: hasAudio ? 'pointer' : 'default', + transform: 'scale(0.8)', + }; + + if (audioLevel !== undefined) { + return ; + } + + return ( + + + + {hasAudio ? ( + + ) : ( + + )} + + + + + ); +}; + +export default AudioIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/index.tsx new file mode 100644 index 00000000..9c86d1ec --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/AudioIndicator/index.tsx @@ -0,0 +1,3 @@ +import AudioIndicator from './AudioIndicator'; + +export default AudioIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.spec.tsx new file mode 100644 index 00000000..b7af69e3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.spec.tsx @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { SessionContextType } from '../../../../Context/SessionProvider/session'; +import useSessionContext from '../../../../hooks/useSessionContext'; +import CaptionsBox from './CaptionsBox'; +import { SubscriberWrapper } from '../../../../types/session'; + +vi.mock('../../../../hooks/useSessionContext'); + +const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; + +describe('CaptionsBox', () => { + let sessionContext: SessionContextType; + + const createSubscriberWrapper = (id: string): SubscriberWrapper => { + const mockSubscriber = { + id, + on: vi.fn(), + off: vi.fn(), + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + stream: { + streamId: id, + }, + } as unknown as Subscriber; + return { + id, + element: document.createElement('video'), + isScreenshare: false, + isPinned: false, + subscriber: mockSubscriber, + }; + }; + + beforeEach(() => { + sessionContext = { + subscriberWrappers: [ + createSubscriberWrapper('subscriber-1'), + createSubscriberWrapper('subscriber-2'), + ], + } as unknown as SessionContextType; + mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); + }); + + it('renders the captions box correctly', () => { + sessionContext.connected = true; + const { getByTestId } = render(); + expect(getByTestId('captions-box')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.tsx new file mode 100644 index 00000000..5df0a138 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/CaptionsBox.tsx @@ -0,0 +1,59 @@ +import { ReactElement } from 'react'; +import Box from '@mui/material/Box'; +import UserCaption from './UserCaption'; +import useSessionContext from '../../../../hooks/useSessionContext'; +import useIsSmallViewport from '../../../../hooks/useIsSmallViewport'; + +/** + * CaptionsBox Component + * + * This component renders the captions of the speakers in the meeting room. + * @returns {ReactElement} The captions box component. + */ +const CaptionsBox = (): ReactElement => { + const { subscriberWrappers, ownCaptions } = useSessionContext(); + const isSmallViewPort = useIsSmallViewport(); + + const sxBox = { + position: 'absolute', + bottom: isSmallViewPort ? 100 : 80, + left: '50%', + transform: 'translateX(-50%)', + px: 2, + py: isSmallViewPort ? 1 : 1.5, + borderRadius: 2, + width: isSmallViewPort ? '90vw' : 600, + height: isSmallViewPort ? 150 : 200, + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }; + + return ( + + {ownCaptions && ( + + )} + {(subscriberWrappers ?? []).map((wrapper, idx) => ( + + ))} + + ); +}; + +export default CaptionsBox; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.spec.tsx new file mode 100644 index 00000000..378a1b6d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.spec.tsx @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { SessionContextType } from '../../../../../Context/SessionProvider/session'; +import useSessionContext from '../../../../../hooks/useSessionContext'; +import UserCaption from './UserCaption'; +import { SubscriberWrapper } from '../../../../../types/session'; + +vi.mock('../../../../../hooks/useSessionContext'); +vi.mock('../../../../../hooks/useReceivingCaptions', () => ({ + default: () => ({ + captionText: '', + isReceivingCaptions: false, + }), +})); + +vi.useFakeTimers(); + +const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; + +describe('UserCaption', () => { + let sessionContext: SessionContextType; + + const createSubscriberWrapper = (id: string): SubscriberWrapper => { + const mockSubscriber = { + id, + on: vi.fn(), + off: vi.fn(), + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + stream: { + streamId: id, + }, + } as unknown as Subscriber; + return { + id, + element: document.createElement('video'), + isScreenshare: false, + isPinned: false, + subscriber: mockSubscriber, + }; + }; + + beforeEach(() => { + sessionContext = { + subscriberWrappers: [], + currentCaptionsId: { + current: '1-2-3-4', + }, + } as unknown as SessionContextType; + mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); + }); + + it('renders the caption when receiving captions', () => { + const { getByText } = render( + + ); + expect(getByText('Test Caption')).toBeInTheDocument(); + }); + + it('does not render when no caption is provided', () => { + const { queryByText } = render( + + ); + expect(queryByText('Test Caption')).not.toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx new file mode 100644 index 00000000..4ee6b6d7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx @@ -0,0 +1,84 @@ +import { Subscriber } from '@vonage/client-sdk-video'; +import { ReactElement, useState, useRef, useEffect } from 'react'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; +import useReceivingCaptions from '../../../../../hooks/useReceivingCaptions'; +import { CAPTION_DISPLAY_DURATION_MS } from '../../../../../utils/constants'; + +export type UserCaptionProps = { + subscriber: Subscriber | null; + isSmallViewPort: boolean; + caption?: string; +}; + +/** + * UserCaption component to display captions for a specific user. + * @param {UserCaptionProps} props - The props for the component. + * @property {Subscriber} subscriber - The subscriber object for which to display captions. + * @property {boolean} isSmallViewPort - whether it is a small viewport (mobile view or small tab). + * @property {string} caption - (optional) the caption text to display. + * @returns {ReactElement | null} - The rendered caption or null if not receiving captions. + */ +const UserCaption = ({ + subscriber, + isSmallViewPort, + caption, +}: UserCaptionProps): ReactElement | null => { + const { t } = useTranslation(); + const { caption: captionText, isReceivingCaptions } = useReceivingCaptions({ + subscriber, + }); + + const displayCaption = caption ?? captionText; + const isActive = Boolean(caption ?? isReceivingCaptions); + + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + if (isActive && displayCaption) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setVisible(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => setVisible(false), CAPTION_DISPLAY_DURATION_MS); + } else { + setVisible(false); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [displayCaption, isActive]); + + if (!visible || !displayCaption) { + return null; + } + + return ( +
        + + {subscriber?.stream?.name ?? t('user.you')}: {displayCaption} + +
        + ); +}; + +export default UserCaption; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/index.tsx new file mode 100644 index 00000000..6dd78b05 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/index.tsx @@ -0,0 +1,3 @@ +import UserCaption from './UserCaption'; + +export default UserCaption; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/index.tsx new file mode 100644 index 00000000..447d959f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/index.tsx @@ -0,0 +1,3 @@ +import CaptionsBox from './CaptionsBox'; + +export default CaptionsBox; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx new file mode 100644 index 00000000..72648e3d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.spec.tsx @@ -0,0 +1,112 @@ +import { describe, expect, it, vi, beforeEach, Mock } from 'vitest'; +import { render as renderBase, screen, act, waitFor } from '@testing-library/react'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { ReactElement } from 'react'; +import { SessionContextType } from '@Context/SessionProvider/session'; +import useSessionContext from '@hooks/useSessionContext'; +import { SubscriberWrapper } from '@app-types/session'; +import { makeTestProvider, providers } from '@test/providers'; +import CaptionsButton, { CaptionsState } from './CaptionsButton'; +import { env } from '../../../env'; +import type { VideoClient } from '@core/services'; + +vi.mock('@hooks/useSessionContext'); + +const mockVideoClient: VideoClient = { + ensureCaptionsEnabled: vi.fn(), + disableCaptions: vi.fn(), +} as unknown as VideoClient; + +const mockUseSessionContext = useSessionContext as Mock<[], SessionContextType>; + +describe('CaptionsButton', () => { + const mockHandleCloseMenu = vi.fn(); + const mockSetIsUserCaptionsEnabled = vi.fn(); + const mockSetCaptionsErrorResponse = vi.fn(); + + const mockCaptionsState = { + isUserCaptionsEnabled: false, + setIsUserCaptionsEnabled: mockSetIsUserCaptionsEnabled, + setCaptionsErrorResponse: mockSetCaptionsErrorResponse, + } as CaptionsState; + const mockedSessionKey = 'test-session-key'; + let sessionContext: SessionContextType; + + const createSubscriberWrapper = (id: string): SubscriberWrapper => { + const mockSubscriber = { + id, + on: vi.fn(), + off: vi.fn(), + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + stream: { + streamId: id, + }, + } as unknown as Subscriber; + return { + id, + element: document.createElement('video'), + isScreenshare: false, + isPinned: false, + subscriber: mockSubscriber, + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + (mockVideoClient.ensureCaptionsEnabled as Mock).mockResolvedValue({ captionsId: '1-2-3-4' }); + (mockVideoClient.disableCaptions as Mock).mockResolvedValue({}); + sessionContext = { + subscriberWrappers: [createSubscriberWrapper('subscriber-1')], + sessionKey: mockedSessionKey, + connected: true, + } as unknown as SessionContextType; + mockUseSessionContext.mockReturnValue(sessionContext as unknown as SessionContextType); + }); + + it('renders the button correctly', () => { + render( + + ); + expect(screen.getByTestId('captions-button')).toBeInTheDocument(); + }); + + it('turns the captions on when button is pressed', async () => { + env.partialUpdate({ + ALLOW_CAPTIONS: true, + }); + render(); + + act(() => screen.getByTestId('captions-button').click()); + + await waitFor(() => { + expect(mockVideoClient.ensureCaptionsEnabled).toHaveBeenCalledWith({ + sessionKey: mockedSessionKey, + }); + }); + }); + + it('is not rendered when allowCaptions is false', () => { + env.partialUpdate({ + ALLOW_CAPTIONS: false, + }); + render(); + + expect(screen.queryByTestId('captions-button')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([providers.runtime], { + runtimeContext: { videoClient: mockVideoClient }, + }); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx new file mode 100644 index 00000000..1797004b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx @@ -0,0 +1,124 @@ +import { Dispatch, ReactElement, SetStateAction } from 'react'; +import { AxiosError } from 'axios'; +import { useTranslation } from 'react-i18next'; +import useSessionContext from '@hooks/useSessionContext'; +import ToolbarButton from '../ToolbarButton'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../../env'; +import { runtime$ } from '@core/stores'; + +export type CaptionsState = { + isUserCaptionsEnabled: boolean; + setIsUserCaptionsEnabled: Dispatch>; + setCaptionsErrorResponse: Dispatch>; +}; + +export type CaptionsButtonProps = { + isOverflowButton?: boolean; + handleClick?: () => void; + captionsState: CaptionsState; +}; + +/** + * CaptionsButton Component + * + * Displays a button and handles the captioning functionality. + * @param {CaptionsButtonProps} props - the props for the component + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @property {(event?: MouseEvent | TouchEvent) => void} handleClick - (optional) click handler that closes the overflow menu in small viewports. + * @property {CaptionsState} captionsState - the state of the captions, including whether they are enabled and functions to set error messages + * @returns {ReactElement | false} - The CaptionsButton component. + */ +const CaptionsButton = ({ + isOverflowButton = false, + handleClick, + captionsState, +}: CaptionsButtonProps): ReactElement | false => { + const videoClient = runtime$.useVideoClient(); + const { t } = useTranslation(); + const { sessionKey } = useSessionContext(); + const { isUserCaptionsEnabled, setIsUserCaptionsEnabled, setCaptionsErrorResponse } = + captionsState; + const title = isUserCaptionsEnabled ? t('captions.disable') : t('captions.enable'); + + const handleClose = () => { + if (isOverflowButton && handleClick) { + handleClick(); + } + }; + + const handleCaptionsErrorResponse = (message: string | null) => { + setCaptionsErrorResponse(message || t('errors.unknown')); + + setIsUserCaptionsEnabled(false); + }; + + const handleCaptionsEnable = async () => { + try { + await videoClient.ensureCaptionsEnabled({ sessionKey: sessionKey! }); + + setIsUserCaptionsEnabled(true); + } catch (error) { + if (error instanceof AxiosError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + handleCaptionsErrorResponse(error.response?.data.message); + } + } + }; + + const handleCaptionsDisable = () => { + try { + setIsUserCaptionsEnabled(false); + } catch (error) { + if (error instanceof AxiosError) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + handleCaptionsErrorResponse(error.response?.data.message); + } + } + }; + + const handleCaptions = async (action: 'enable' | 'disable') => { + if (action === 'enable') { + await handleCaptionsEnable(); + } else if (action === 'disable') { + handleCaptionsDisable(); + } + }; + + const handleActionClick = () => { + void handleCaptions(isUserCaptionsEnabled ? 'disable' : 'enable'); + handleClose(); + }; + + return ( + env.ALLOW_CAPTIONS && ( + + + ) : ( + + ) + } + sx={{ + marginTop: isOverflowButton ? '0px' : '4px', + }} + isOverflowButton={isOverflowButton} + /> + + ) + ); +}; +export default CaptionsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/index.tsx new file mode 100644 index 00000000..380d0035 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsButton/index.tsx @@ -0,0 +1,3 @@ +import CaptionsButton from './CaptionsButton'; + +export default CaptionsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.spec.tsx new file mode 100644 index 00000000..2b1c319f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.spec.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi, beforeEach, afterAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import CaptionsError from './CaptionsError'; + +describe('CaptionsError', () => { + const mockSetCaptionsErrorResponse = vi.fn(); + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('renders nothing when captionsErrorResponse is null', () => { + render( + + ); + expect(screen.queryByText(/Captions error:/)).toBeNull(); + }); + + it('renders error message when there is an error in captions', () => { + render( + + ); + expect(screen.getByText(/Captions error: Failed to fetch captions/)).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.tsx new file mode 100644 index 00000000..f253423d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/CaptionsError.tsx @@ -0,0 +1,49 @@ +import { Dispatch, ReactElement, SetStateAction } from 'react'; +import { useTranslation } from 'react-i18next'; +import { env } from '../../../env'; +import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; + +export type CaptionsErrorProps = { + captionsErrorResponse: string | null; + setCaptionsErrorResponse: Dispatch>; +}; + +/** + * CaptionsError Component + * + * Displays an error message when there is an issue with the captions service. + * @param {CaptionsErrorProps} props - the props for the component + * @property {string | null} captionsErrorResponse - the error message to display + * @property {Dispatch>} setCaptionsErrorResponse - function to set the error message to null when the snackbar is closed + * @returns {ReactElement} - The CaptionsError component. + */ +const CaptionsError = ({ + captionsErrorResponse, + setCaptionsErrorResponse, +}: CaptionsErrorProps): ReactElement => { + const { t } = useTranslation(); + const isSmallViewport = useIsSmallViewport(); + return ( + setCaptionsErrorResponse(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + sx={{ mb: isSmallViewport ? 10 : 6 }} + data-testid="captions-error" + className="bg-vera-surface!" + > + setCaptionsErrorResponse(null)} + severity="error" + sx={{ width: isSmallViewport ? '80%' : '100%' }} + > + {t('captions.errors', { captionsErrorResponse })} + + + ); +}; + +export default CaptionsError; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/index.tsx new file mode 100644 index 00000000..49c758bc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/CaptionsError/index.tsx @@ -0,0 +1,3 @@ +import CaptionsError from './CaptionsError'; + +export default CaptionsError; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.spec.tsx new file mode 100644 index 00000000..9075ceba --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.spec.tsx @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render as renderBase, screen, within } from '@testing-library/react'; +import { ReactElement } from 'react'; +import Chat from './Chat'; +import { ChatMessageType } from '../../../types/chat'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import { SessionContextType } from '../../../Context/SessionProvider/session'; + +const testMessages: ChatMessageType[] = [ + { + participantName: 'User One', + timestamp: 1726587657728, + message: 'Hello all', + }, + { + participantName: 'User Two', + timestamp: 1726587657729, + message: 'Good morning', + }, + { + participantName: 'User Three', + timestamp: 1726587657730, + message: 'Hi', + }, + { + participantName: 'User Four', + timestamp: 1726587657731, + message: 'Sup', + }, +]; + +describe('Chat', () => { + afterEach(() => { + cleanup(); + }); + + it('should display messages', () => { + render( {}} isOpen />, { + sessionContext: { + __interceptor: (context: SessionContextType) => { + if (context) { + context.messages = testMessages; + } + }, + }, + }); + + const chatMessages = screen.getAllByTestId('chat-message'); + expect(chatMessages.length).toBe(4); + + expect(within(chatMessages[0]).getByTestId('chat-msg-participant-name').textContent).toEqual( + 'User One' + ); + expect(within(chatMessages[0]).getByTestId('chat-msg-timestamp').textContent).toEqual( + '11:40 AM' + ); + expect(chatMessages[0].textContent).toMatch('Hello all'); + }); +}); + +type RenderOptions = { + sessionContext?: ProviderOptions['SessionContext']; + userContext?: ProviderOptions['UserContext']; +}; + +function render(ui: ReactElement, { sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.tsx new file mode 100644 index 00000000..81ead33c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/Chat.tsx @@ -0,0 +1,72 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import getInitials from '../../../utils/getInitials'; +import getParticipantColor from '../../../utils/getParticipantColor'; +import ChatMessage from '../ChatMessage'; +import ChatInput from '../ChatInput'; +import useSessionContext from '../../../hooks/useSessionContext'; +import RightPanelTitle from '../RightPanel/RightPanelTitle'; +import List from '@mui/material/List'; +import Box from '@mui/material/Box'; + +export type ChatProps = { + handleClose: () => void; + isOpen: boolean; +}; + +/** + * Chat component + * Renders a scrollable chat container + * @param {ChatProps} props - props for this component + * @property {() => void} handleClose - close handler + * @property {boolean} isOpen - true if chat is open + * @returns {ReactElement | false} - Chat component + */ +const Chat = ({ handleClose, isOpen }: ChatProps): ReactElement | false => { + const { t } = useTranslation(); + const { messages } = useSessionContext(); + + return ( + isOpen && ( + <> + + + + {messages.map((msg) => { + return ( + + ); + })} + + + + + + + ) + ); +}; + +export default Chat; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/index.tsx new file mode 100644 index 00000000..19fedf94 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Chat/index.tsx @@ -0,0 +1,3 @@ +import Chat from './Chat'; + +export default Chat; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.spec.tsx new file mode 100644 index 00000000..b8d4bc8b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.spec.tsx @@ -0,0 +1,76 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import ChatButton from './ChatButton'; +import { env } from './../../../env'; + +describe('ChatButton', () => { + it('should show unread message number', () => { + render( {}} isOpen={false} />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.unreadCount = 10; + } + }, + }, + }); + expect(screen.getByTestId('chat-button-unread-count')).toBeVisible(); + expect(screen.getByTestId('chat-button-unread-count').textContent).toBe('10'); + }); + + it('should not show unread message number when number is 0', () => { + render( {}} isOpen={false} />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.unreadCount = 0; + } + }, + }, + }); + + const badge = screen.getByTestId('chat-button-unread-count'); + // Check badge is hidden: MUI hides badge by setting dimensions to 0x0 + expect(badge.offsetHeight).toBe(0); + expect(badge.offsetWidth).toBe(0); + }); + + it('should invoke callback on click', () => { + const handleClick = vi.fn(); + render(); + screen.getByRole('button').click(); + expect(handleClick).toHaveBeenCalled(); + }); + + it('is not rendered when allowChat is false', () => { + env.partialUpdate({ + ALLOW_CHAT: false, + }); + render( {}} isOpen />); + + expect(screen.queryByTestId('ChatIcon')).not.toBeInTheDocument(); + }); +}); + +type RenderOptions = { + sessionContext?: ProviderOptions['SessionContext']; + userContext?: ProviderOptions['UserContext']; +}; + +function render(ui: ReactElement, { sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx new file mode 100644 index 00000000..a70a9e7d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx @@ -0,0 +1,62 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import ToolbarButton from '../ToolbarButton'; +import UnreadMessagesBadge from '../UnreadMessagesBadge'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../../env'; + +export type ChatButtonProps = { + handleClick: () => void; + isOpen: boolean; + isOverflowButton?: boolean; +}; + +/** + * ChatButton Component + * + * Toolbar button to open and close the chat panel. + * Also displays an unread message count badge. + * @param {ChatButtonProps} props - the props for this component + * @property {() => void} handleClick - click handler to toggle open chat panel + * @property {boolean} isOpen - true if chat is currently open, false if not + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @returns {ReactElement | false} - ChatButton + */ +const ChatButton = ({ + handleClick, + isOpen, + isOverflowButton = false, +}: ChatButtonProps): ReactElement | false => { + const { t } = useTranslation(); + + return ( + env.ALLOW_CHAT && ( + + + + } + isOverflowButton={isOverflowButton} + /> + + + ) + ); +}; + +export default ChatButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/index.tsx new file mode 100644 index 00000000..6b39cbfa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatButton/index.tsx @@ -0,0 +1,3 @@ +import ChatButton from './ChatButton'; + +export default ChatButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.spec.tsx new file mode 100644 index 00000000..fd0d9973 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.spec.tsx @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render as renderBase, screen, fireEvent, cleanup } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import ChatInput from './ChatInput'; +import { ChatMessageType } from '../../../types/chat'; + +const testMessages: ChatMessageType[] = [ + { + participantName: 'User One', + timestamp: 1726587657728, + message: 'Hello all', + }, +]; + +const sendChatMessageMock = vi.fn(); + +describe('ChatInput', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders the chat input field', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + expect(input).toBeInTheDocument(); + }); + + it('does not send a message when composing', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + expect(sendChatMessageMock).not.toHaveBeenCalled(); + + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + expect(sendChatMessageMock).not.toHaveBeenCalled(); + }); + + it('sends a message on Enter when not composing', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + + fireEvent.change(input, { target: { value: testMessages[0].message } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + + expect(sendChatMessageMock).toHaveBeenCalledWith(testMessages[0].message); + }); + + it('does not send a message on Enter if Shift is pressed', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + + fireEvent.change(input, { target: { value: testMessages[0].message } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); + + expect(sendChatMessageMock).not.toHaveBeenCalled(); + }); + + it('trims whitespace before sending a message', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + + fireEvent.change(input, { target: { value: ` ${testMessages[0].message} ` } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + + expect(sendChatMessageMock).toHaveBeenCalledWith(`${testMessages[0].message}`); + }); + + it('does not send an empty message', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.sendChatMessage = sendChatMessageMock; + } + }, + }, + }); + const input = screen.getByPlaceholderText('Send a message'); + const sendButton = screen.getByRole('button'); + + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.click(sendButton); + expect(sendChatMessageMock).not.toHaveBeenCalled(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render(ui: ReactElement, { userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.tsx new file mode 100644 index 00000000..d5e637e4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/ChatInput.tsx @@ -0,0 +1,94 @@ +import { KeyboardEvent, ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useSessionContext from '../../../hooks/useSessionContext'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import VividIcon from '@ui/VividIcon'; + +/** + * ChatInput component + * + * Renders a text input with a send button + * and sends message via signaling on send. + * @returns {ReactElement} - ChatInput component + */ +const ChatInput = (): ReactElement => { + const { t } = useTranslation(); + const [text, setText] = useState(''); + const [isComposing, setIsComposing] = useState(false); + const { sendChatMessage } = useSessionContext(); + + const handleSendMessage = () => { + // Ensure composition has ended before sending the message + if (isComposing) { + return; + } + const trimmedText = text.trim(); + if (trimmedText.length) { + sendChatMessage(trimmedText); + } + setText(''); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // If enter press then send message unless shift also pressed to allow for multiline messages + if (e.key === 'Enter' && !e.shiftKey && !isComposing) { + e.preventDefault(); + handleSendMessage(); + } + }; + + const handleCompositionStart = () => { + setIsComposing(true); + }; + + const handleCompositionEnd = () => { + setIsComposing(false); + }; + + return ( + setText(e.target.value)} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} + value={text} + maxRows={5} + fullWidth + className="bg-vera-background" + sx={{ + margin: '16px', + minHeight: '48px', + borderRadius: '20px', + flexDirection: 'row', + '&.MuiTextField-root': { + paddingLeft: '24px', + }, + }} + slotProps={{ + input: { + endAdornment: ( + + + + + + ), + disableUnderline: true, + }, + }} + /> + ); +}; + +export default ChatInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/index.tsx new file mode 100644 index 00000000..294eb7bd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatInput/index.tsx @@ -0,0 +1,3 @@ +import ChatInput from './ChatInput'; + +export default ChatInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx new file mode 100644 index 00000000..219583cf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx @@ -0,0 +1,92 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getFormattedTime } from '../../../utils/dateTime'; +import FormattedMessageBody from '../FormattedMessageBody'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; + +export type ChatMessageProps = { + avatarColor: string; + initials: string; + message: string; + name: string; + timestamp: number; +}; +/** + * ChatMessage component + * + * Renders an MUI ListItem with a chat message. + * For each message, it renders an Avatar with initials, the sender name, a formatted time, and the message. + * @param {ChatMessageProps} props - props for the component + * @property {string} avatarColor - color for avatar + * @property {string} initials - initials for avatar + * @property {string} message - chat message contents + * @property {string} name - sender name + * @property {number} timestamp - message timestamp + * @returns {ReactElement} the ChatMessage Component + */ +const ChatMessage = ({ + avatarColor, + initials, + message, + name, + timestamp, +}: ChatMessageProps): ReactElement => { + const { i18n } = useTranslation(); + + return ( + + + {initials} + + + + {name} + + + {getFormattedTime(i18n.language, timestamp)} + + + } + secondary={ + + + + } + /> + + ); +}; + +export default ChatMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/index.tsx new file mode 100644 index 00000000..768720a7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ChatMessage/index.tsx @@ -0,0 +1,3 @@ +import ChatMessage from './ChatMessage'; + +export default ChatMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx new file mode 100644 index 00000000..eb97f199 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.spec.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest'; +import { fireEvent, screen, render as renderBase } from '@testing-library/react'; +import { Publisher } from '@vonage/client-sdk-video'; +import { EventEmitter } from 'stream'; +import { ReactElement } from 'react'; +import { PublisherContextType } from '@Context/PublisherProvider'; +import useSpeakingDetector from '@hooks/useSpeakingDetector'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { defaultAudioDevice } from '@utils/mockData/device'; +import { makeTestProvider } from '@test/providers'; +import DeviceControlButton from './DeviceControlButton'; +import { env } from '../../../env'; +import enTranslations from '../../../locales/en.json'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@hooks/usePublisherContext.tsx'); +vi.mock('@hooks/useSpeakingDetector.tsx'); +const toggleBackgroundVideoPublisherMock = vi.fn(); +vi.mock('@hooks/useBackgroundPublisherContext', () => { + return { + default: () => ({ + toggleVideo: toggleBackgroundVideoPublisherMock, + }), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'devices.audio.microphone.full': enTranslations['devices.audio.microphone.full'], + 'devices.video.camera.full': enTranslations['devices.video.camera.full'], + 'devices.settings.ariaLabel': enTranslations['devices.settings.ariaLabel'], + 'devices.video.disabled': enTranslations['devices.video.disabled'], + 'devices.audio.disabled': enTranslations['devices.audio.disabled'], + 'mutedAlert.message.muted': enTranslations['mutedAlert.message.muted'], + }; + return translations[key] || key; + }, + }), +})); + +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; +const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>; +const mockHandleToggleBackgroundEffects = vi.fn(); + +describe('DeviceControlButton', () => { + let mockPublisher: Publisher; + let publisherContext: PublisherContextType; + + beforeEach(() => { + env.partialUpdate({ + ALLOW_MICROPHONE_CONTROL: true, + ALLOW_CAMERA_CONTROL: true, + }); + mockPublisher = Object.assign(new EventEmitter(), { + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + getAudioSource: () => defaultAudioDevice, + videoWidth: () => 1280, + videoHeight: () => 720, + }) as unknown as Publisher; + publisherContext = { + publisherContext: null, + isPublishing: true, + publish: vi.fn() as () => Promise, + initializeLocalPublisher: vi.fn(() => { + publisherContext.publisher = mockPublisher; + }) as unknown as () => void, + } as unknown as PublisherContextType; + mockUsePublisherContext.mockImplementation(() => publisherContext); + mockUseSpeakingDetector.mockReturnValue(false); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('updates the main publisher and the background replacement publisher when clicked', () => { + const toggleVideoMock = vi.fn(); + mockUsePublisherContext.mockImplementation(() => ({ + ...publisherContext, + toggleAudio: vi.fn(), + toggleVideo: toggleVideoMock, + isAudioEnabled: true, + isVideoEnabled: true, + })); + render( + + ); + const cameraButton = screen.getByLabelText('Camera'); + cameraButton.click(); + expect(toggleVideoMock).toHaveBeenCalled(); + expect(toggleBackgroundVideoPublisherMock).toHaveBeenCalled(); + }); + + describe('audio DeviceControlButton', () => { + it('renders by default', () => { + render( + + ); + const micButton = screen.getByLabelText('Microphone'); + expect(micButton).toBeInTheDocument(); + expect(micButton).not.toBeDisabled(); + + expect(screen.getByTestId('vivid-icon-chevron-up-line')).toBeInTheDocument(); + }); + + it('renders the button as disabled with greyed out icon and correct tooltip when allowMicrophoneControl is false', async () => { + env.partialUpdate({ + ALLOW_MICROPHONE_CONTROL: false, + ALLOW_CAMERA_CONTROL: true, + }); + + render( + + ); + const micButton = screen.getByLabelText('Microphone'); + expect(micButton).toBeInTheDocument(); + expect(micButton).toBeDisabled(); + + const tooltip = screen.getByLabelText('device settings'); + fireEvent.mouseOver(tooltip); + expect( + await screen.findByText('Microphone control is disabled in this application') + ).toBeInTheDocument(); + }); + }); + + describe('video DeviceControlButton', () => { + it('is rendered when it is configured to be enabled', () => { + render( + + ); + + const videoButton = screen.getByLabelText('Camera'); + expect(videoButton).toBeInTheDocument(); + expect(videoButton).not.toBeDisabled(); + + expect(screen.getByTestId('vivid-icon-chevron-up-line')).toBeInTheDocument(); + }); + + it('renders the button as disabled with greyed out icon and correct tooltip when allowCameraControl is false', async () => { + env.partialUpdate({ + ALLOW_MICROPHONE_CONTROL: true, + ALLOW_CAMERA_CONTROL: false, + }); + + render( + + ); + + const videoButton = screen.getByLabelText('Camera'); + expect(videoButton).toBeInTheDocument(); + expect(videoButton).toBeDisabled(); + + const tooltip = screen.getByLabelText('device settings'); + fireEvent.mouseOver(tooltip); + expect( + await screen.findByText('Camera control is disabled in this application') + ).toBeInTheDocument(); + }); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx new file mode 100644 index 00000000..d5a28d1e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx @@ -0,0 +1,210 @@ +import { useState, useRef, useCallback, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import usePublisherContext from '@hooks/usePublisherContext'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import getControlButtonTooltip from '@utils/getControlButtonTooltip'; +import DeviceSettingsMenu from '../DeviceSettingsMenu'; +import MutedAlert from '../../MutedAlert'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +import usePushToTalk from '@hooks/usePushToTalk'; +import { env } from '../../../env'; + +export type DeviceControlButtonProps = { + deviceType: 'audio' | 'video'; + toggleBackgroundEffects: () => void; +}; + +/** + * DeviceControlButton Component + * + * This component displays a current status of audio/video device (camera/microphone enabled/disabled) + * and shows a dropdown that displays available audio/video devices. + * @param {DeviceControlButtonProps} props - the props for the component. + * @property {boolean} deviceType - indicates the type of the device to control. + * @property {Function} toggleBackgroundEffects - function to toggle background effects for video devices. + * @returns {ReactElement} The DeviceControlButton component. + */ +const DeviceControlButton = ({ + deviceType, + toggleBackgroundEffects, +}: DeviceControlButtonProps): ReactElement => { + const { t } = useTranslation(); + const { isVideoEnabled, toggleAudio, toggleVideo, isAudioEnabled } = usePublisherContext(); + const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); + + const isAudio = deviceType === 'audio'; + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const isButtonDisabled = isAudio ? !env.ALLOW_MICROPHONE_CONTROL : !env.ALLOW_CAMERA_CONTROL; + + const tooltipTitle = getControlButtonTooltip({ + isAudio, + isAudioEnabled, + isVideoEnabled, + allowMicrophoneControl: env.ALLOW_MICROPHONE_CONTROL, + allowCameraControl: env.ALLOW_CAMERA_CONTROL, + t, + }); + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = useCallback((event: MouseEvent | TouchEvent) => { + if (anchorRef?.current?.contains(event.target as Node)) { + return; + } + setOpen(false); + }, []); + + const renderControlIcon = () => { + if (isAudio) { + if (!env.ALLOW_MICROPHONE_CONTROL) { + return ( + + ); + } + if (isAudioEnabled) { + return ( + + ); + } + return ( + + ); + } + + if (!env.ALLOW_CAMERA_CONTROL) { + return ( + + ); + } + if (isVideoEnabled) { + return ( + + ); + } + return ( + + ); + }; + + const handleDeviceStateChange = () => { + if (isAudio) { + toggleAudio(); + } else { + toggleVideo(); + toggleBackgroundVideoPublisher(); + } + }; + + usePushToTalk({ + enabled: isAudio && env.ALLOW_MICROPHONE_CONTROL, + isAudioEnabled, + toggleAudio, + }); + + return ( + <> + {isAudio && } + + + {open ? ( + + ) : ( + + )} + + + + {/* We add the Box here so that the tooltip is present if the button is disabled */} + + {renderControlIcon()} + + + + + + + ); +}; + +export default DeviceControlButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/index.tsx new file mode 100644 index 00000000..e86bc6d5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceControlButton/index.tsx @@ -0,0 +1,3 @@ +import DeviceControlButton from './DeviceControlButton'; + +export default DeviceControlButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx new file mode 100644 index 00000000..d332075c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.spec.tsx @@ -0,0 +1,375 @@ +import { + act, + fireEvent, + queryByText, + screen, + waitFor, + render as renderBase, +} from '@testing-library/react'; +import { describe, beforeEach, it, vi, expect } from 'vitest'; +import { ReactElement, RefObject } from 'react'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import type { MediaDeviceInfoJSON } from '@web/types'; +import { makeTestProvider } from '@test/providers'; +import { isSinkIdSupported } from '@web/platform'; +import { env } from '../../../env'; +import { + makeMediaDeviceInfos, + makeMediaStreamMock, + setupWindowNavigatorMock, +} from '@web-test/fixtures'; +import { mediaDevices$ } from '@core/stores'; +import DeviceSettingsMenuComponent from './DeviceSettingsMenu'; + +const { mockHasMediaProcessorSupport, mockGetActiveAudioOutputDevice, mockSetAudioOutputDevice } = + vi.hoisted(() => { + return { + mockHasMediaProcessorSupport: vi.fn().mockReturnValue(true), + mockGetActiveAudioOutputDevice: vi.fn(), + mockSetAudioOutputDevice: vi.fn(), + }; + }); + +vi.mock('@vonage/client-sdk-video', () => ({ + hasMediaProcessorSupport: mockHasMediaProcessorSupport, + getActiveAudioOutputDevice: mockGetActiveAudioOutputDevice, + setAudioOutputDevice: mockSetAudioOutputDevice, +})); + +vi.mock('@web/platform'); + +const someDevices = makeMediaDeviceInfos(); + +describe('DeviceSettingsMenu Component', () => { + const mockHandleToggle = vi.fn(); + const mockHandleToggleBackgroundEffects = vi.fn(); + const mockSetIsOpen = vi.fn(); + const mockAnchorRef = { + current: document.createElement('input'), + } as RefObject; + const mockHandleClose = vi.fn(); + + let defaultSpeakers: MediaDeviceInfoJSON; + let usbHeadsetSpeakers: MediaDeviceInfoJSON; + let bluetoothSpeakers: MediaDeviceInfoJSON; + + beforeEach(() => { + vi.mocked(isSinkIdSupported).mockReturnValue(true); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(someDevices), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + }); + + // Mock HTMLAudioElement methods for SoundTest + vi.spyOn(HTMLAudioElement.prototype, 'pause').mockImplementation(vi.fn()); + vi.spyOn(HTMLAudioElement.prototype, 'play').mockResolvedValue(undefined); + + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices, + })); + + const audioOutputDevices = Object.values(mediaDevices$.mediaDevicesMap$.getState().audiooutput); + [defaultSpeakers, usbHeadsetSpeakers, bluetoothSpeakers] = audioOutputDevices; + + // Mock Vonage SDK audio output functions + mockGetActiveAudioOutputDevice.mockResolvedValue(defaultSpeakers); + mockSetAudioOutputDevice.mockImplementation(() => Promise.resolve()); + + vi.mocked(hasMediaProcessorSupport).mockReturnValue(false); + }); + + describe('renders the audio settings menu', () => { + const deviceType = 'audio'; + + it('and renders the output devices if the browser supports setting audioOutput device', async () => { + vi.mocked(isSinkIdSupported).mockReturnValue(true); + + const selectDeviceSpy = vi.spyOn(mediaDevices$.actions, 'selectDevice'); + + render( + + ); + + const outputDevicesElement = screen.getByTestId('output-devices'); + await waitFor(() => expect(outputDevicesElement.children).to.have.length(3)); + expect(outputDevicesElement.firstChild).toHaveTextContent(defaultSpeakers.label); + + expect( + (outputDevicesElement.children[1] as HTMLOptionElement).classList.contains('Mui-selected') + ).toBe(false); + + expect(outputDevicesElement.children[1]).toHaveTextContent(usbHeadsetSpeakers.label); + expect(outputDevicesElement.children[2]).toHaveTextContent(bluetoothSpeakers.label); + + // test will fail without the await act + // eslint-disable-next-line @typescript-eslint/require-await + await act(async () => { + fireEvent.click(outputDevicesElement.children[2]); + }); + + // Verify the store action was called + expect(selectDeviceSpy).toHaveBeenCalledWith('audiooutput', bluetoothSpeakers.deviceId); + }); + + it('and renders the default output device if the browser does not support setting audioOutput device', async () => { + vi.mocked(isSinkIdSupported).mockReturnValue(false); + + render( + + ); + + const outputDevicesElement = screen.getByTestId('output-devices'); + await waitFor(() => expect(outputDevicesElement.children).to.have.length(1)); + expect(outputDevicesElement.firstChild).toHaveTextContent('System Default'); + expect( + (outputDevicesElement.firstChild as HTMLOptionElement).classList.contains('Mui-selected') + ).toBe(true); + + act(() => { + fireEvent.click(outputDevicesElement.firstChild as HTMLOptionElement); + }); + + // Verify the Vonage SDK function was not called + expect(mockSetAudioOutputDevice).not.toHaveBeenCalled(); + expect( + (outputDevicesElement.firstChild as HTMLOptionElement).classList.contains('Mui-selected') + ).toBe(true); + }); + + it('and renders the speaker test if the browser supports audio output device selection', async () => { + vi.mocked(isSinkIdSupported).mockReturnValue(true); + + await act(() => + render( + + ) + ); + + const outputDevicesElement = screen.getByTestId('output-devices'); + expect(outputDevicesElement).toBeInTheDocument(); + }); + + it('and renders the speaker test devices if the browser does not support audio output device selection', async () => { + vi.mocked(isSinkIdSupported).mockReturnValue(false); + vi.mocked(isSinkIdSupported).mockReturnValue(false); + + await act(() => + render( + + ) + ); + + const soundTest = screen.queryByTestId('soundTest'); + expect(soundTest).toBeInTheDocument(); + }); + + it('and updates audio output device list if device is removed', async () => { + vi.mocked(isSinkIdSupported).mockReturnValue(true); + + const selectDeviceSpy = vi.spyOn(mediaDevices$.actions, 'selectDevice'); + + render( + + ); + + const outputDevicesElement = screen.getByTestId('output-devices'); + + // Check initial list is correct + await waitFor(() => expect(outputDevicesElement.children).to.have.length(3)); + expect(outputDevicesElement.firstChild).toHaveTextContent(defaultSpeakers.label); + expect(outputDevicesElement.children[1]).toHaveTextContent(usbHeadsetSpeakers.label); + expect(outputDevicesElement.children[2]).toHaveTextContent(bluetoothSpeakers.label); + + // select device 2 + // eslint-disable-next-line @typescript-eslint/require-await + await act(async () => { + fireEvent.click(outputDevicesElement.children[1] as HTMLOptionElement); + }); + + // Verify the store action was called + expect(selectDeviceSpy).toHaveBeenCalledWith('audiooutput', usbHeadsetSpeakers.deviceId); + + // Simulate device 2 removal - remove USB headset + const devicesWithoutUSB = someDevices.filter( + (d) => d.deviceId !== usbHeadsetSpeakers.deviceId + ); + + act(() => { + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: devicesWithoutUSB, + })); + }); + + await waitFor(() => expect(outputDevicesElement.children).to.have.length(2)); + expect(outputDevicesElement.firstChild).toHaveTextContent(defaultSpeakers.label); + // Note: After device removal, the first device (default) should be selected + // but the component doesn't automatically update currentAudioOutputDevice context + // It only updates when user explicitly selects a device or when reconciliation happens + expect(outputDevicesElement.children[1]).toHaveTextContent(bluetoothSpeakers.label); + const removedDevice = queryByText(outputDevicesElement, usbHeadsetSpeakers.label); + expect(removedDevice).not.toBeInTheDocument(); + }); + }); + + describe('renders the video effects menu', () => { + const deviceType = 'video'; + it('if prompted', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('video-settings-devices-dropdown')).toBeInTheDocument(); + }); + }); + + it('but does not render it if closed', async () => { + render( + + ); + await waitFor(() => { + expect(screen.queryByTestId('video-settings-devices-dropdown')).not.toBeInTheDocument(); + }); + }); + + it('and renders the dropdown separator and background effects option when media processor is supported', async () => { + vi.mocked(hasMediaProcessorSupport).mockReturnValue(true); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('dropdown-separator')).toBeVisible(); + expect(screen.queryByText('Video effects')).toBeVisible(); + }); + }); + + it('and does not render the dropdown separator and video effects option when media processor is not supported', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('dropdown-separator')).not.toBeInTheDocument(); + expect(screen.queryByText('video effects')).not.toBeInTheDocument(); + }); + }); + + it('and does not render the dropdown separator and video effects option when allowBackgroundEffects is false', async () => { + env.partialUpdate({ + ALLOW_BACKGROUND_EFFECTS: false, + MEETING_ROOM_ALLOW_DEVICE_SELECTION: true, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('dropdown-separator')).not.toBeInTheDocument(); + expect(screen.queryByText('video effects')).not.toBeInTheDocument(); + }); + }); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx new file mode 100644 index 00000000..3994059a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx @@ -0,0 +1,139 @@ +import { ReactElement, RefObject, Dispatch, SetStateAction } from 'react'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import InputDevices from '../InputAudioDevices'; +import OutputDevices from '../OutputAudioDevices'; +import ReduceNoiseTestSpeakers from '../ReduceNoiseTestSpeakers'; +import useDropdownResizeObserver from '../../../hooks/useDropdownResizeObserver'; +import VideoDevices from '../VideoDevices'; +import DropdownSeparator from '../DropdownSeparator'; +import VideoDevicesOptions from '../VideoDevicesOptions'; +import Popper from '@mui/material/Popper'; +import Grow from '@mui/material/Grow'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Paper from '@mui/material/Paper'; +import Box from '@mui/material/Box'; +import { env } from '../../../env'; + +export type DeviceSettingsMenuProps = { + deviceType: 'audio' | 'video'; + handleToggle: () => void; + toggleBackgroundEffects: () => void; + isOpen: boolean; + anchorRef: RefObject; + handleClose: (event: MouseEvent | TouchEvent) => void; + setIsOpen: Dispatch>; +}; + +/** + * DeviceSettingsMenu Component + * + * This component renders a pop up that includes options to: + * - select audio input and output devices + * - select video input device + * - on supported devices, an option to enable advanced noise suppression + * - on supported devices, an option to blur the video background + * @param {DeviceSettingsMenuProps} props - the props for this component. + * @property {boolean} deviceType - indicates the type of the device to control. + * @property {Function} handleToggle - the function that handles the toggle of video input device. + * @property {Function} toggleBackgroundEffects - the function that toggles background effects for video devices. + * @property {boolean} isOpen - the prop that shows whether the pop up needs to be opened. + * @property {RefObject} anchorRef - the anchor element to attach the pop up to. + * @property {Function} handleClose - the function that handles the closing of the pop up. + * @property {Function} setIsOpen - the function to set the open state of the pop up. + * @returns {ReactElement} - the DeviceSettingsMenu component. + */ +const DeviceSettingsMenu = ({ + deviceType, + handleToggle, + toggleBackgroundEffects, + isOpen, + anchorRef, + handleClose, + setIsOpen, +}: DeviceSettingsMenuProps): ReactElement | false => { + const isAudio = deviceType === 'audio'; + const shouldDisplayBackgroundEffects = + hasMediaProcessorSupport('both') && env.ALLOW_BACKGROUND_EFFECTS; + + const handleToggleBackgroundEffects = () => { + toggleBackgroundEffects(); + handleToggle(); + }; + + useDropdownResizeObserver({ setIsOpen, dropDownRefElement: anchorRef.current }); + + const renderSettingsMenu = () => { + if (isAudio) { + return ( + <> + + + + + ); + } + + return ( + <> + + {shouldDisplayBackgroundEffects && ( + <> + + + + )} + + ); + }; + + return ( + + {({ TransitionProps, placement }) => ( + + + + ({ + padding: { xs: 1, sm: 2 }, + borderRadius: 2, + zIndex: 1, + transform: isAudio + ? 'translateY(-2%) translateX(5%)' + : 'translateY(-5%) translateX(-15%)', + [t.breakpoints.down(741)]: { + transform: isAudio + ? 'translateY(-2%) translateX(-10%)' + : 'translateY(-5%) translateX(-40%)', + }, + [t.breakpoints.down(450)]: { + transform: isAudio + ? 'translateY(-2%) translateX(-5%)' + : 'translateY(-5%) translateX(-5%)', + }, + width: { xs: '90vw', sm: '100%' }, + maxWidth: 400, + position: 'relative', + })} + > + {renderSettingsMenu()} + + + + + )} + + ); +}; + +export default DeviceSettingsMenu; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/index.tsx new file mode 100644 index 00000000..978e6d40 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DeviceSettingsMenu/index.tsx @@ -0,0 +1,3 @@ +import DeviceSettingsMenu from './DeviceSettingsMenu'; + +export default DeviceSettingsMenu; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/DropdownSeparator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/DropdownSeparator.tsx new file mode 100644 index 00000000..11c52fb6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/DropdownSeparator.tsx @@ -0,0 +1,23 @@ +import { ReactElement } from 'react'; +import Box from '@mui/material/Box'; + +/** + * DropdownSeparator Component + * + * This component renders a horizontal separator line. + * @returns {ReactElement} The DropdownSeparator component. + */ +const DropdownSeparator = (): ReactElement => { + return ( + + ); +}; + +export default DropdownSeparator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/index.tsx new file mode 100644 index 00000000..e4efe394 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/DropdownSeparator/index.tsx @@ -0,0 +1,3 @@ +import DropdownSeparator from './DropdownSeparator'; + +export default DropdownSeparator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.spec.tsx new file mode 100644 index 00000000..607a5a8e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.spec.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import Emoji from './index'; +import { EMOJI_DISPLAY_DURATION } from '../../../utils/constants'; + +describe('Emoji component', () => { + const emojiWrapper = { + emoji: '😀', + name: 'Happy Face', + time: Date.now(), + }; + + it('renders emoji and name label correctly', () => { + render(); + + expect(screen.getByText(emojiWrapper.emoji)).toBeInTheDocument(); + expect(screen.getByText(emojiWrapper.name)).toBeInTheDocument(); + }); + + it('applies correct inline styles including animationDuration', () => { + render(); + + const container = screen.getByTestId('emoji-string-container'); + const expectedDuration = EMOJI_DISPLAY_DURATION + 100; + + expect(container).toHaveStyle({ + position: 'absolute', + animationName: 'moveEmoji', + animationDuration: `${expectedDuration}ms`, + animationTimingFunction: 'linear', + animationIterationCount: '1', + maxWidth: '35%', + zIndex: '1', + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.tsx new file mode 100644 index 00000000..2bf40c3b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/Emoji.tsx @@ -0,0 +1,57 @@ +import { CSSProperties, ReactElement } from 'react'; +import { EmojiWrapper } from '../../../hooks/useEmoji'; +import { EMOJI_DISPLAY_DURATION } from '../../../utils/constants'; +import Chip from '@mui/material/Chip'; +import Box from '@mui/material/Box'; +import useIsSmallViewport from '@hooks/useIsSmallViewport'; + +export type EmojiProps = { + emojiWrapper: EmojiWrapper; +}; + +const style: CSSProperties = { + position: 'absolute', + animationName: 'moveEmoji', + // Adding an extra 100 ms to ensure the emoji does not re-render at bottom of page on animation end + animationDuration: `${EMOJI_DISPLAY_DURATION + 100}ms`, + animationTimingFunction: 'linear', + animationIterationCount: 1, + maxWidth: '35%', + zIndex: 1, +}; + +/** + * Emoji Component + * + * Displays an emoji sent from a user in the meeting. + * @param {EmojiProps} props - The props for the component. + * @returns {ReactElement} - The Emoji Component. + */ +const Emoji = ({ emojiWrapper }: EmojiProps): ReactElement => { + const isSmallViewport = useIsSmallViewport(); + const { emoji, name } = emojiWrapper; + + return ( + + {emoji} + + + ); +}; + +export default Emoji; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/index.tsx new file mode 100644 index 00000000..29856719 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Emoji/index.tsx @@ -0,0 +1,3 @@ +import Emoji from './Emoji'; + +export default Emoji; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/EmojiGrid/EmojiGrid.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/EmojiGrid/EmojiGrid.spec.tsx new file mode 100644 index 00000000..579af668 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/EmojiGrid/EmojiGrid.spec.tsx @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ReactElement, useRef, useState } from 'react'; +import Button from '@mui/material/Button'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import EmojiGrid from './EmojiGrid'; +import SendEmojiButton from '../SendEmojiButton'; + +vi.mock('@mui/material/useMediaQuery', () => ({ + default: vi.fn(), +})); +vi.mock('../SendEmojiButton'); +vi.mock('../../../utils/emojis', () => ({ + default: { FAVORITE: '🦧' }, +})); + +const mockSendEmojiButton = SendEmojiButton as Mock<[], ReactElement>; + +const FakeSendEmojiButton = + + + + ); +}; + +export default MutingDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/MutingDialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/MutingDialog/index.tsx new file mode 100644 index 00000000..3e6d7297 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/MutingDialog/index.tsx @@ -0,0 +1,3 @@ +import MutingDialog from './MutingDialog'; + +export default MutingDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/NameDisplay.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/NameDisplay.tsx new file mode 100644 index 00000000..1b0ee97c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/NameDisplay.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from 'react'; + +export type NameDisplayProps = { + containerWidth: number; + name: string; +}; + +/** + * NameDisplay Component + * + * This component shows a truncated name within a specified container width. + * @param {NameDisplayProps} props - the props for the component. + * @property {number} containerWidth - the width of the container to determine the max width for truncation. + * @property {string} name - the name to be displayed. + * @returns {ReactElement} The NameDisplay component. + */ +const NameDisplay = ({ name, containerWidth }: NameDisplayProps): ReactElement => { + const safeMaxWidth = + typeof containerWidth === 'number' && Number.isFinite(containerWidth) ? containerWidth : 0; + return ( +
        + {name} +
        + ); +}; + +export default NameDisplay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/index.tsx new file mode 100644 index 00000000..b7106703 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/NameDisplay/index.tsx @@ -0,0 +1,3 @@ +import NameDisplay from './NameDisplay'; + +export default NameDisplay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputAudioDevices.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputAudioDevices.tsx new file mode 100644 index 00000000..51068fcd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputAudioDevices.tsx @@ -0,0 +1,152 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { MediaDeviceInfoJSON } from '@web/types'; +import classNames from 'classnames'; +import DropdownSeparator from '../DropdownSeparator'; +import Box from '@mui/material/Box'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import VividIcon from '@ui/VividIcon'; +import { useDistinctLabelMediaDevices } from '@ui/hooks'; +import { isSinkIdSupported } from '@web/platform'; +import mediaDevices$ from '@core/stores/devices'; +import { Tooltip } from '@mui/material'; +import { env } from '../../../env'; +import useSelectDeviceHandler from '@hooks/useSelectDeviceHandler'; +import { makeApplicationErrorMapper } from '@core/errors'; +import { handleClientApplicationError } from '@ui/helpers'; + +export type OutputAudioDevicesProps = { + handleToggle: () => void; +}; + +/** + * OutputAudioDevices Component + * + * Displays and switches audio output devices. + * @param {OutputAudioDevicesProps} props - The props for the component. + * @property {() => void} handleToggle - Function to close the menu. + * @returns {ReactElement | false} - The OutputAudioDevices component. + */ +const OutputAudioDevices = ({ handleToggle }: OutputAudioDevicesProps): ReactElement | false => { + const { t } = useTranslation(); + const currentAudioOutputId = mediaDevices$.useDeviceId('audiooutput'); + + const availableDevices = useDistinctLabelMediaDevices('audiooutput', (devices) => + isSinkIdSupported() + ? devices.map((device) => + // Rename default audio output device to user-friendly label + device.deviceId === 'default' + ? { ...device, label: t('devices.audio.defaultLabel') } + : device + ) + : [{ deviceId: 'default', label: t('devices.audio.defaultLabel') } as MediaDeviceInfoJSON] + ); + + const { handleSelectDevice } = useSelectDeviceHandler(); + + const handleChangeAudioOutput = async (deviceId: string) => { + try { + handleToggle(); + + if (!isSinkIdSupported()) return; + + await handleSelectDevice({ + deviceId, + mediaDeviceKind: 'audiooutput', + }); + } catch (error) { + handleClientApplicationError(makeApplicationErrorMapper()(error)); + } + }; + + return ( + env.MEETING_ROOM_ALLOW_DEVICE_SELECTION && ( + <> + + + +

        + {t('devices.audio.speakers.full')} +

        +
        + + + {availableDevices?.map((device: MediaDeviceInfoJSON) => { + // If audio output device selection is not supported we show the default device as selected + const isSelected = + device.deviceId === currentAudioOutputId || availableDevices.length === 1; + + return ( + handleChangeAudioOutput(device.deviceId)} + className="[&.Mui-selected]:text-vera-on-background" + sx={{ + transition: 'background-color 160ms ease', + '&&.Mui-selected': { + backgroundColor: 'var(--vera-background)', + }, + '&&.Mui-selected:hover': { + backgroundColor: 'var(--vera-background)', + }, + '&&:hover': { + backgroundColor: 'var(--vera-background)', + }, + }} + > + + {isSelected ? ( + + + + ) : ( + + )} + + {device.label} + + + + ); + })} + + + ) + ); +}; + +export default OutputAudioDevices; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputDevices.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputDevices.spec.tsx new file mode 100644 index 00000000..7dae5fdf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/OutputDevices.spec.tsx @@ -0,0 +1,157 @@ +import { + makeMediaDeviceInfos, + makeMediaStreamMock, + mockPlatformModule, + setupWindowNavigatorMock, +} from '@web-test/fixtures'; +import { describe, it, beforeEach, vi, expect } from 'vitest'; +import { render as renderBase, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ReactElement } from 'react'; +import type { MediaDeviceInfoJSON } from '@web/types'; +import { makeTestProvider } from '@test/providers'; +import { isSinkIdSupported } from '@web/platform'; +import mediaDevices$ from '@core/stores/devices'; +import { env } from '../../../env'; +import OutputAudioDevices from './OutputAudioDevices'; + +vi.mock('@web/platform', async () => { + const actual = await vi.importActual('@web/platform'); + + return mockPlatformModule(actual, { + isSinkIdSupported: true, + }); +}); + +const someDevices = makeMediaDeviceInfos(); + +describe('OutputAudioDevices Component', () => { + const mockHandleToggle = vi.fn(); + + let defaultSpeakers: MediaDeviceInfoJSON; + let usbHeadsetSpeakers: MediaDeviceInfoJSON; + let bluetoothSpeakers: MediaDeviceInfoJSON; + + beforeEach(() => { + mediaDevices$.reset(); + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices, + })); + + const audioOutputDevices = Object.values(mediaDevices$.mediaDevicesMap$.getState().audiooutput); + [defaultSpeakers, usbHeadsetSpeakers, bluetoothSpeakers] = audioOutputDevices; + + // after initializing the store to avoid having to mock all the mediaDevices$ sync logic. + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(someDevices), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + }); + }); + + it('renders all available audio output devices when supported', () => { + render(); + + expect(screen.getByText('Speakers')).toBeInTheDocument(); + expect(screen.getByTestId('output-devices')).toBeInTheDocument(); + + // Check that specific audio output devices are rendered + expect(screen.getByText(defaultSpeakers.label)).toBeInTheDocument(); + expect(screen.getByText(usbHeadsetSpeakers.label)).toBeInTheDocument(); + expect(screen.getByText(bluetoothSpeakers.label)).toBeInTheDocument(); + }); + + it('renders only default device when audio output is not supported', () => { + // Mock isSinkIdSupported to return false + vi.mocked(isSinkIdSupported).mockReturnValue(false); + + render(); + + expect(screen.getByText('Speakers')).toBeInTheDocument(); + expect(screen.getByText('System Default')).toBeInTheDocument(); + + // Should not render the actual audio output devices + expect(screen.queryByText(defaultSpeakers.label)).not.toBeInTheDocument(); + }); + + it('changes audio output device on menu item click when supported', async () => { + const selectDeviceSpy = vi.spyOn(mediaDevices$.actions, 'selectDevice'); + + render(); + + const speakerItem = screen.getByText(usbHeadsetSpeakers.label); + fireEvent.click(speakerItem); + + expect(mockHandleToggle).toHaveBeenCalledTimes(1); + expect(selectDeviceSpy).toHaveBeenCalledWith('audiooutput', usbHeadsetSpeakers.deviceId); + + await waitFor(() => { + expect(mediaDevices$.getState().audiooutput === usbHeadsetSpeakers.deviceId).toBeTruthy(); + }); + }); + + it('does not call selectDevice when audio output is not supported', () => { + vi.mocked(isSinkIdSupported).mockReturnValue(false); + + const selectDeviceSpy = vi.spyOn(mediaDevices$.actions, 'selectDevice'); + + render(); + + const defaultItem = screen.getByText('System Default'); + fireEvent.click(defaultItem); + + expect(mockHandleToggle).toHaveBeenCalledTimes(1); + expect(selectDeviceSpy).not.toHaveBeenCalled(); + }); + + it('shows selection state based on store', () => { + // Without selecting a device in the store, no device should be marked as selected + render(); + + // All menu items should be rendered but none marked as selected (no Mui-selected class) + const menuItems = screen.getAllByRole('menuitem'); + expect(menuItems).toHaveLength(3); + + // None should have the selected class since currentAudioOutputId is null + menuItems.forEach((item) => { + expect(item).not.toHaveClass('Mui-selected'); + }); + }); + + it('shows check icon for default device when only one device available', () => { + vi.mocked(isSinkIdSupported).mockReturnValue(false); + + render(); + + // When only default device is available (length === 1), it should be selected. + const checkIcon = screen.getByTestId('vivid-icon-check-line'); + expect(checkIcon).toBeInTheDocument(); + }); + + it('is not rendered when allowDeviceSelection is false', () => { + env.partialUpdate({ + MEETING_ROOM_ALLOW_DEVICE_SELECTION: false, + }); + + render(); + + expect(screen.queryByTestId('output-device-title')).not.toBeInTheDocument(); + expect(screen.queryByTestId('output-devices')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/index.tsx new file mode 100644 index 00000000..d224c178 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/OutputAudioDevices/index.tsx @@ -0,0 +1,3 @@ +import OutputDevices from './OutputAudioDevices'; + +export default OutputDevices; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/Index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/Index.tsx new file mode 100644 index 00000000..1cb98cd2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/Index.tsx @@ -0,0 +1,3 @@ +import ParticipantList from './ParticipantList'; + +export default ParticipantList; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.spec.tsx new file mode 100644 index 00000000..b8726516 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.spec.tsx @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { + cleanup, + render as renderBase, + screen, + within, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ReactElement } from 'react'; +import { Subscriber as OTSubscriber } from '@vonage/client-sdk-video'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { SubscriberWrapper } from '@app-types/session'; +import useRoomShareUrl from '@hooks/useRoomShareUrl'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import ParticipantList from './ParticipantList'; + +const mockedRoomName = { roomName: 'test-room-name' }; + +vi.mock('@hooks/useRoomShareUrl'); +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), + useParams: () => mockedRoomName, +})); +const mockNavigate = vi.fn(); + +const createSubscriberWrapper = ( + name: string, + id: string, + isScreenshare: boolean = false +): SubscriberWrapper => { + const videoType = isScreenshare ? 'screen' : 'camera'; + return { + id, + element: document.createElement('video'), + isPinned: false, + isScreenshare, + subscriber: { + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + on: vi.fn(), + off: vi.fn(), + stream: { + streamId: id, + videoType, + name, + }, + } as unknown as OTSubscriber, + }; +}; + +const createTestSubscriberWrappers = () => { + return [ + createSubscriberWrapper('James Holden', 'sub1'), + // Screen share subscribers should be hidden in list + createSubscriberWrapper("James Holden's screen", 'sub1', true), + createSubscriberWrapper('Alex Kamal', 'sub2'), + createSubscriberWrapper('Chrisjen Avasarala', 'sub3'), + createSubscriberWrapper('Amos', 'sub4'), + createSubscriberWrapper('Naomi Nagata', 'sub5'), + createSubscriberWrapper('', 'sub6'), + ]; +}; + +describe('ParticipantList', () => { + let originalClipboard: Clipboard; + + beforeEach(() => { + originalClipboard = navigator.clipboard; + Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, + }); + }); + afterEach(() => { + Object.assign(navigator, { clipboard: originalClipboard }); + cleanup(); + }); + + it('does not render when closed', () => { + render( {}} />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + expect(screen.queryByText('Participants')).not.toBeInTheDocument(); + }); + + it('copies room share URL to clipboard', async () => { + (useRoomShareUrl as Mock).mockReturnValue('https://example.com/room123'); + + render( {}} />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + + const copyButton = screen.getByTestId('vivid-icon-copy-line'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://example.com/room123'); + expect(screen.getByTestId('vivid-icon-check-sent-line')).toBeInTheDocument(); + }); + }); + + it('should display remote participants in alphabetical order with local participant first', () => { + (useNavigate as Mock).mockReturnValue(mockNavigate); + (useLocation as Mock).mockReturnValue({ + state: mockedRoomName, + }); + render( {}} isOpen />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + + const namesInOrder = screen + .getAllByTestId('participant-list-item', { exact: false }) + .map((listItem) => { + return within(listItem).getByTestId('participant-list-name').textContent; + }); + expect(namesInOrder).toEqual([ + 'Local Participant (You)', + 'Alex Kamal', + 'Amos', + 'Chrisjen Avasarala', + 'James Holden', + 'Naomi Nagata', + '', // Edge case, empty names go at the bottom + ]); + }); + + it('filters list by query and hides You when not matching', () => { + render( {}} isOpen />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + + const input = screen.getByPlaceholderText('Search participants'); + fireEvent.change(input, { target: { value: 'alex' } }); + + const names = screen + .getAllByTestId('participant-list-item', { exact: false }) + .map((el) => within(el).getByTestId('participant-list-name').textContent); + + expect(names).toEqual(['Alex Kamal']); + expect(screen.queryByText('Local Participant (You)')).not.toBeInTheDocument(); + }); + + it('shows You when query matches local participant name (case-insensitive)', () => { + render( {}} isOpen />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + + const input = screen.getByPlaceholderText('Search participants'); + fireEvent.change(input, { target: { value: 'LOCAL' } }); + + const names = screen + .getAllByTestId('participant-list-item', { exact: false }) + .map((el) => within(el).getByTestId('participant-list-name').textContent); + + expect(names).toEqual(['Local Participant (You)']); + }); + + it('restores full list after clearing query', () => { + render( {}} isOpen />, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = createTestSubscriberWrappers(); + } + }, + }, + userContext: { + __interceptor: (context) => { + if (context) { + context.user.defaultSettings.name = 'Local Participant'; + } + }, + }, + }); + + const input = screen.getByPlaceholderText('Search participants'); + fireEvent.change(input, { target: { value: 'zzz' } }); + + expect(screen.queryAllByTestId('participant-list-item', { exact: false }).length).toBe(0); + + fireEvent.change(input, { target: { value: '' } }); + + const namesInOrder = screen + .getAllByTestId('participant-list-item', { exact: false }) + .map((listItem) => { + return within(listItem).getByTestId('participant-list-name').textContent; + }); + expect(namesInOrder).toEqual([ + 'Local Participant (You)', + 'Alex Kamal', + 'Amos', + 'Chrisjen Avasarala', + 'James Holden', + 'Naomi Nagata', + '', + ]); + }); +}); + +type RenderOptions = { + sessionContext?: ProviderOptions['SessionContext']; + userContext?: ProviderOptions['UserContext']; +}; + +function render(ui: ReactElement, { sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx new file mode 100644 index 00000000..54ff7c9b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx @@ -0,0 +1,193 @@ +import { ReactElement, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useSessionContext from '@hooks/useSessionContext'; +import useUserContext from '@hooks/useUserContext'; +import useAudioLevels from '@hooks/useAudioLevels'; +import ParticipantListItem from '../ParticipantListItem'; +import getInitials from '@utils/getInitials'; +import getParticipantColor from '@utils/getParticipantColor'; +import useRoomShareUrl from '@hooks/useRoomShareUrl'; +import RightPanelTitle from '../RightPanel/RightPanelTitle'; +import usePublisherContext from '@hooks/usePublisherContext'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import List from '@mui/material/List'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import VividIcon from '@ui/VividIcon'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; + +import createNameMatcher from '@utils/participantList/createNameMatcher'; +import getFilteredSubscribers from '@utils/participantList/getFilteredSubscribers'; +import shouldShowUser from '@utils/participantList/shouldShowUser'; + +export type ParticipantListProps = { + handleClose: () => void; + isOpen: boolean; +}; + +/** + * ParticipantList Component + * + * This component shows a list of the participants that are currently in the meeting room. + * It also has a meeting URL link that can be copied to the clipboard. + * @param {ParticipantListProps} props - the props for the component. + * @property {Function} handleClose - a function that handles closing of the participant list. + * @property {boolean} isOpen - a variable that shows whether the participant list should be displayed. + * @returns {ReactElement} The participant list component. + */ +const ParticipantList = ({ handleClose, isOpen }: ParticipantListProps): ReactElement | false => { + const { t } = useTranslation(); + const { subscriberWrappers } = useSessionContext(); + const publisherAudio = useAudioLevels(); + const [isCopied, setIsCopied] = useState(false); + const [query, setQuery] = useState(''); + const { + user: { + defaultSettings: { name }, + }, + } = useUserContext(); + const roomShareUrl = useRoomShareUrl(); + const { isAudioEnabled } = usePublisherContext(); + + const { filteredSubscriberWrappers, isUserVisible, participantCount } = useMemo(() => { + const nameMatches = createNameMatcher(query); + const filteredSubscriberWrappersLocal = getFilteredSubscribers({ + subscriberWrappers, + nameMatches, + }); + const isUserVisibleLocal = shouldShowUser(nameMatches, name); + const participantCountLocal = + (isUserVisibleLocal ? 1 : 0) + filteredSubscriberWrappersLocal.length; + + return { + nameMatches, + filteredSubscriberWrappers: filteredSubscriberWrappersLocal, + isUserVisible: isUserVisibleLocal, + participantCount: participantCountLocal, + }; + }, [subscriberWrappers, query, name]); + + const copyUrl = () => { + void navigator.clipboard.writeText(roomShareUrl); + + setIsCopied(true); + + // reset the icon back after a brief timeout + setTimeout(() => { + setIsCopied(false); + }, 3000); + }; + return ( + isOpen && ( + <> + + +
        + {t('chat.meetingUrl')} + + {window.location.href} + +
        + + + {isCopied ? ( + + ) : ( + + )} + + +
        + + setQuery(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + {isUserVisible && ( + + )} + {filteredSubscriberWrappers.map((subscriberWrapper) => { + const { subscriber, id } = subscriberWrapper; + const hasAudio = !!subscriber.stream?.hasAudio; + const participantName: string = subscriber?.stream?.name ?? ''; + const participantStream = subscriber?.stream; + return ( + + ); + })} + + + ) + ); +}; + +export default ParticipantList; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx new file mode 100644 index 00000000..4f6debc3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx @@ -0,0 +1,75 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import ToolbarButton from '../ToolbarButton'; +import Badge from '@mui/material/Badge'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../../env'; + +export type ParticipantListButtonProps = { + handleClick: () => void; + isOpen: boolean; + participantCount: number; + isOverflowButton?: boolean; +}; +/** + * ParticipantListButton Component + * + * Toolbar button to open and close participant list + * Also displays participant count badge + * @param {ParticipantListButtonProps} props - the props for this component + * @property {() => void} handleClick - click handler to toggle open participant list + * @property {boolean} isOpen - true if list is currently open, false if not + * @property {number} participantCount - number of current participants in call, to be displayed in badge + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @returns {ReactElement} - ParticipantListButton + */ +const ParticipantListButton = ({ + handleClick, + isOpen, + participantCount, + isOverflowButton = false, +}: ParticipantListButtonProps): ReactElement | false => { + const { t } = useTranslation(); + + return ( + env.SHOW_PARTICIPANT_LIST && ( + + + + } + isOverflowButton={isOverflowButton} + /> + + + ) + ); +}; + +export default ParticipantListButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx new file mode 100644 index 00000000..fc0e9f50 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantsListButton.spec.tsx @@ -0,0 +1,38 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { makeTestProvider } from '@test/providers'; +import ParticipantListButton from './ParticipantListButton'; +import { env } from '../../../env'; + +describe('ParticipantListButton', () => { + it('should show participant number', () => { + render( {}} isOpen={false} participantCount={10} />); + expect(screen.getByText('10')).toBeVisible(); + }); + + it('should invoke callback on click', () => { + const handleClick = vi.fn(); + render(); + screen.getByRole('button').click(); + expect(handleClick).toHaveBeenCalled(); + }); + it('is not rendered when showParticipantList is false', () => { + env.partialUpdate({ + SHOW_PARTICIPANT_LIST: false, + }); + + render( {}} isOpen={false} participantCount={10} />); + + expect(screen.queryByTestId('participant-list-button')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/index.tsx new file mode 100644 index 00000000..edf31627 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListButton/index.tsx @@ -0,0 +1,3 @@ +import ParticipantListButton from './ParticipantListButton'; + +export default ParticipantListButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.spec.tsx new file mode 100644 index 00000000..2609a036 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.spec.tsx @@ -0,0 +1,128 @@ +import { describe, expect, it, beforeEach, vi, Mock } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Stream } from '@vonage/client-sdk-video'; +import ParticipantListItem, { ParticipantListItemProps } from './ParticipantListItem'; +import useAudioLevels from '../../../hooks/useAudioLevels'; +import usePublisherContext from '../../../hooks/usePublisherContext'; +import { PublisherContextType } from '../../../Context/PublisherProvider'; + +vi.mock('../../../hooks/useAudioLevels'); +vi.mock('../../../hooks/usePublisherContext'); + +const mockUseAudioLevels = useAudioLevels as Mock<[], number | undefined>; +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; + +describe('ParticipantListItem', () => { + const mockStream: Stream = { + connection: { connectionId: 'mock-connection-id', creationTime: Date.now(), data: 'mockData' }, + streamId: 'mock-stream-id', + creationTime: Date.now(), + hasAudio: true, + hasVideo: false, + name: 'John Doe', + videoDimensions: { width: 640, height: 480 }, + videoType: 'camera', + frameRate: 1, + initials: 'JD', + hasCaptions: false, + }; + const defaultProps: ParticipantListItemProps = { + audioLevel: 50, + avatarColor: 'rgb(0, 0, 255)', + initials: mockStream.initials, + hasAudio: true, + stream: mockStream, + name: 'John Doe', + dataTestId: 'participant-list-item', + }; + + it('renders the participant name', () => { + render(); + const nameElement = screen.getByTestId('participant-list-name'); + expect(nameElement).toHaveTextContent('John Doe'); + }); + + it('applies the correct avatar color', () => { + render(); + const avatarElement = screen.getByText('JD'); + expect(avatarElement).toHaveStyle(`background-color: ${defaultProps.avatarColor}`); + }); + + it('renders the initials in the Avatar', () => { + render(); + const avatarElement = screen.getByText('JD'); + expect(avatarElement).toBeInTheDocument(); + }); + + it('renders the ListItem with correct height and data-testid', () => { + render(); + const listItem = screen.getByTestId('participant-list-item'); + expect(listItem).toBeInTheDocument(); + expect(listItem).toHaveStyle({ height: '56px' }); + }); + + it('handles long participant names with noWrap styling', () => { + render( + + ); + const nameElement = screen.getByTestId('participant-list-name'); + expect(nameElement).toHaveTextContent('A very long participant name that should be truncated'); + expect(nameElement).toHaveStyle({ whiteSpace: 'nowrap' }); + }); + + it('uses the correct Avatar dimensions and font size', () => { + render(); + const avatarElement = screen.getByText('JD'); + expect(avatarElement).toHaveStyle({ + width: '32px', + height: '32px', + fontSize: '14px', + }); + }); + + describe('Publisher audio state handling', () => { + let publisherContext: PublisherContextType; + beforeEach(() => { + publisherContext = { + isAudioEnabled: true, + } as unknown as PublisherContextType; + mockUsePublisherContext.mockImplementation(() => publisherContext); + mockUseAudioLevels.mockReturnValue(50); + }); + + it('shows active audio state for publisher when microphone is enabled', () => { + mockUsePublisherContext.mockImplementation(() => ({ + ...publisherContext, + isAudioEnabled: true, + })); + mockUseAudioLevels.mockReturnValue(75); + + render(); + + const participantItem = screen.getByTestId('participant-list-item'); + expect(participantItem).toBeInTheDocument(); + + const audioIndicator = screen.getByTestId('audio-indicator'); + expect(audioIndicator).toBeInTheDocument(); + + const micIcon = screen.getByTestId('MicIcon'); + expect(micIcon).toBeInTheDocument(); + }); + + it('shows muted state when hasAudio is false', () => { + render(); + + const participantItem = screen.getByTestId('participant-list-item'); + expect(participantItem).toBeInTheDocument(); + + const audioIndicator = screen.getByTestId('audio-indicator'); + expect(audioIndicator).toBeInTheDocument(); + + const micOffIcon = screen.getByTestId('vivid-icon-mic-mute-solid'); + expect(micOffIcon).toBeInTheDocument(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.tsx new file mode 100644 index 00000000..be318759 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/ParticipantListItem.tsx @@ -0,0 +1,108 @@ +import { ReactElement } from 'react'; +import { Stream } from '@vonage/client-sdk-video'; +import AudioIndicator from '../AudioIndicator'; +import ParticipantListItemMenu from '../ParticipantListItemMenu'; +import { SubscriberWrapper } from '../../../types/session'; +import ListItem from '@mui/material/ListItem'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import Badge from '@mui/material/Badge'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; + +export type ParticipantListItemProps = { + stream?: Stream; + initials: string; + hasAudio?: boolean; + audioLevel?: number; + name: string; + dataTestId: string; + avatarColor: string; + subscriberWrapper?: SubscriberWrapper; +}; + +/** + * ParticipantListItem component + * List Item displaying a participant's Avatar, name, and audio enabled icon for the Participant List + * @param {ParticipantListItemProps} props - the props for this component + * @property {number} [audioLevel] - participants audio level + * @property {string} avatarColor - color for initials avatar + * @property {string} initials - participant initials + * @property {boolean} hasAudio - participant's audio enabled status + * @property {Stream} stream - participant's stream + * @property {string} name - participant name + * @property {string} dataTestId - ID for testing + * @returns {ReactElement} ParticipantListItem + */ +const ParticipantListItem = ({ + audioLevel, + avatarColor, + dataTestId, + hasAudio, + initials, + name, + stream, + subscriberWrapper, +}: ParticipantListItemProps): ReactElement => { + return ( + + + {subscriberWrapper && ( + + )} +
        + } + > + + } + > + + {initials} + + + + {name} + + + ); +}; + +export default ParticipantListItem; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/index.tsx new file mode 100644 index 00000000..b9af0037 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItem/index.tsx @@ -0,0 +1,3 @@ +import ParticipantListItem from './ParticipantListItem'; + +export default ParticipantListItem; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.spec.tsx new file mode 100644 index 00000000..1231f77f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.spec.tsx @@ -0,0 +1,150 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { act, cleanup, render as renderBase, screen } from '@testing-library/react'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { SubscriberWrapper } from '../../../types/session'; +import ParticipantListItemMenu from '.'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import { ReactElement } from 'react'; + +describe('ParticipantListItem', () => { + const mockSubscriberWrapper: SubscriberWrapper = { + id: 'subId', + isPinned: false, + isScreenshare: false, + subscriber: {} as Subscriber, + element: {} as HTMLVideoElement, + }; + const defaultProps = { + participantName: 'John Doe', + subscriberWrapper: mockSubscriberWrapper, + }; + + const mockPinSubscriber = vi.fn(); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('closes menu after clicking menu item', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.isMaxPinned = false; + context.pinSubscriber = mockPinSubscriber; + } + }, + }, + }); + const menuButton = screen.getByRole('button'); + act(() => menuButton.click()); + expect(screen.getByTestId('pin-menu-item')).toBeVisible(); + const pinButton = screen.getByText('Pin John Doe'); + act(() => pinButton.click()); + expect(screen.queryByTestId('pin-menu-item')).not.toBeInTheDocument(); + }); + + it('can pin participant', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.isMaxPinned = false; + context.pinSubscriber = mockPinSubscriber; + } + }, + }, + }); + const menuButton = screen.getByRole('button'); + act(() => menuButton.click()); + const pinButton = screen.getByText('Pin John Doe'); + act(() => pinButton.click()); + expect(mockPinSubscriber).toHaveBeenCalledWith('subId'); + }); + + it('can unpin participant', () => { + render( + , + { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.isMaxPinned = false; + context.pinSubscriber = mockPinSubscriber; + } + }, + }, + } + ); + const menuButton = screen.getByRole('button'); + act(() => menuButton.click()); + const pinButton = screen.getByText('Unpin John Doe'); + act(() => pinButton.click()); + expect(mockPinSubscriber).toHaveBeenCalledWith('subId'); + }); + + it('cannot pin participant if maximum number of tiles are pinned', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.isMaxPinned = true; + context.pinSubscriber = mockPinSubscriber; + } + }, + }, + }); + const menuButton = screen.getByRole('button'); + act(() => menuButton.click()); + const pinButton = screen.getByText(/You can't pin any more tiles/); + act(() => pinButton.click()); + expect(mockPinSubscriber).not.toHaveBeenCalled(); + }); + + it('can still unpin participant if maximum number of tiles are pinned', () => { + render( + , + { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.isMaxPinned = true; + context.pinSubscriber = mockPinSubscriber; + } + }, + }, + } + ); + const menuButton = screen.getByRole('button'); + act(() => menuButton.click()); + const pinButton = screen.getByText('Unpin John Doe'); + act(() => pinButton.click()); + expect(mockPinSubscriber).toHaveBeenCalledWith('subId'); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render(ui: ReactElement, { userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.tsx new file mode 100644 index 00000000..f67e84af --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantListItemMenu.tsx @@ -0,0 +1,73 @@ +import { useState, MouseEvent } from 'react'; +import type { ReactElement } from 'react'; +import type { SubscriberWrapper } from '../../../types/session'; +import ParticipantPinMenuItem from './ParticipantPinMenuItem'; +import IconButton from '@mui/material/IconButton'; +import Popper from '@mui/material/Popper'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import MenuList from '@mui/material/MenuList'; +import Paper from '@mui/material/Paper'; +import VividIcon from '@ui/VividIcon'; + +export type ParticipantListItemMenuProps = { + participantName: string; + subscriberWrapper: SubscriberWrapper; +}; +/** + * ParticipantListItemMenu + * renders a kebab menu button which opens a menu containing a + * button to pin the participant. + * @param {ParticipantListItemMenuProps} props - component props. + * @property {string} participantName - participant name. + * @property {SubscriberWrapper} subscriberWrapper - The SubscriberWrapper for the participant. + * @returns {ReactElement} - ParticipantListItemMenu + */ +const ParticipantListItemMenu = ({ + participantName, + subscriberWrapper, +}: ParticipantListItemMenuProps): ReactElement => { + const [anchorEl, setAnchorEl] = useState(null); + const isOpen = !!anchorEl; + const handleClick = (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + + + + + + + + + + ); +}; + +export default ParticipantListItemMenu; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantPinMenuItem.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantPinMenuItem.tsx new file mode 100644 index 00000000..33e531c1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/ParticipantPinMenuItem.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { SubscriberWrapper } from '../../../types/session'; +import useSessionContext from '../../../hooks/useSessionContext'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import VividIcon from '@ui/VividIcon'; + +export type ParticipantPinMenuItemProps = { + handleClick: () => void; + participantName: string; + subscriberWrapper: SubscriberWrapper; +}; + +/** + * ParticipantPinMenuItem + * renders a MenuItem button to pin or unpin a participant + * @param {ParticipantPinMenuItemProps} props - component props. + * @property {Function} handleClick - click handler for item + * @property {string} participantName - participant name. + * @property {SubscriberWrapper} subscriberWrapper - The SubscriberWrapper for the participant. + * @returns {ReactElement} - ParticipantPinMenuItem + */ +const ParticipantPinMenuItem = ({ + handleClick, + participantName, + subscriberWrapper, +}: ParticipantPinMenuItemProps): ReactElement => { + const { t } = useTranslation(); + const { isPinned, id } = subscriberWrapper; + const { isMaxPinned, pinSubscriber } = useSessionContext(); + const isDisabled = !isPinned && isMaxPinned; + + const getText = () => { + if (isPinned) { + return t('participants.unpin', { participantName }); + } + if (isMaxPinned) { + return t('participants.maxPin'); + } + return t('participants.pin', { participantName }); + }; + const handlePinClick = () => { + if (!isDisabled) { + pinSubscriber(id); + } + handleClick(); + }; + return ( + + + {isPinned ? ( + + ) : ( + + )} + + + + {getText()} + + + ); +}; +export default ParticipantPinMenuItem; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/index.tsx new file mode 100644 index 00000000..6c9cc279 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ParticipantListItemMenu/index.tsx @@ -0,0 +1,3 @@ +import ParticipantListItemMenu from './ParticipantListItemMenu'; + +export default ParticipantListItemMenu; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx new file mode 100644 index 00000000..49301dfd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx @@ -0,0 +1,124 @@ +import { MouseEvent, ReactElement, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import isMouseEventInsideBox from '../../../utils/isMouseEventInsideBox'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +import { ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE } from '@utils/constants'; +import toRemValue from '@common/helpers/toRemValue'; + +export type PinButtonProps = { + isMaxPinned: boolean; + isPinned: boolean; + isTileHovered: boolean; + participantName?: string; + handleClick: (clickEvent: MouseEvent) => void; +}; + +/** + * PinButton Component + * + * This component renders a button to pin and unpin a participants video, as well as displaying current pinned status. + * @param {PinButtonProps} pinButtonProps - component props + * @property {boolean} isMaxPinned - Indicates whether the maximum number of participants have already been pinned. + * @property {boolean} isPinned - Indicates whether this participant is pinned. + * @property {boolean} isTileHovered - Indicates whether the video tile is being hovered. + * @property {string | undefined} participantName - the name of the participant that can be muted. + * @property {Function} handleClick - click handler, used to toggle participant isPinned state. + * @returns {ReactElement | false} PinButton + */ +const PinButton = ({ + isMaxPinned, + isPinned, + isTileHovered, + participantName, + handleClick, +}: PinButtonProps): ReactElement | false => { + const { t } = useTranslation(); + const isDisabled = isMaxPinned && !isPinned; + const anchorRef = useRef(null); + const [isHoveringButton, setIsHoveringButton] = useState(false); + const iconStyle = { color: isDisabled ? 'var(--vera-disabled)' : 'var(--vera-accent)' }; + + const getTooltipText = () => { + if (isDisabled) { + return t('participants.maxPin'); + } + if (isPinned) { + return t('participants.unpin.video', { participantName }); + } + return t('participants.pin.video', { participantName }); + }; + + const onClick = (clickEvent: MouseEvent) => { + handleClick(clickEvent); + // We set hovering to false manually since onMouseLeave is not invoked when the DOM Element is moved. + setIsHoveringButton(false); + // In case the DOM Element didn't move, which can happen if pinning while viewing screenshare - + // we use setTimeout to let the new layout render, then check if the element is still under the click event location. + // If so we re-enable the hover state. + setTimeout(() => { + if (anchorRef.current) { + const divRect = anchorRef.current.getBoundingClientRect(); + if (isMouseEventInsideBox(clickEvent, divRect)) { + setIsHoveringButton(true); + } + } + }, 0); + }; + + const shouldShowIcon = isTileHovered || isPinned; + const pinButtonHorizontalOffsetRemValue = 0.3; + const pinButtonLeftOffsetRemValue = + ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE + pinButtonHorizontalOffsetRemValue; + + return ( + shouldShowIcon && ( + setIsHoveringButton(true)} + onPointerLeave={() => setIsHoveringButton(false)} + onBlur={() => setIsHoveringButton(false)} + > + + + {isTileHovered && isPinned ? ( + + ) : ( + + )} + + + + ) + ); +}; + +export default PinButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/index.tsx new file mode 100644 index 00000000..53448552 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PinButton/index.tsx @@ -0,0 +1,3 @@ +import PinButton from './PinButton'; + +export default PinButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.spec.tsx new file mode 100644 index 00000000..5c5438b3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.spec.tsx @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import PopupAlert from './PopupAlert'; + +vi.mock('../../../hooks/useIsSmallViewport'); + +const mockUseIsSmallViewport = useIsSmallViewport as Mock<[], boolean>; + +describe('PopupAlert', () => { + beforeEach(() => { + mockUseIsSmallViewport.mockReturnValue(false); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('rendering', () => { + it('renders the title and message', () => { + render(); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test message')).toBeInTheDocument(); + }); + + it.each(['warning', 'error', 'info'] as const)('renders with "%s" severity', (severity) => { + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('does not render a close button when closable is false', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders a close button when closable is true', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.tsx new file mode 100644 index 00000000..14f7e8a9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/PopupAlert.tsx @@ -0,0 +1,85 @@ +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import type { SxProps } from '@mui/material'; +import { ReactElement, useState } from 'react'; +import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import useOnMountEffect from '@web/hooks/useMountEffect'; + +export type PopupAlertProps = { + title: string; + message: string; + closable?: boolean; + severity: 'warning' | 'error' | 'info'; + timeout?: number; +}; + +/** + * PopupAlert Component + * An MUI Alert to display the title and message for connection issues. + * @param {PopupAlertProps} props - the props for this component + * @property {boolean} [closable] (optional) default false - whether alert should be closable + * @property {string} title - Alert title + * @property {string} message - Alert message body + * @property {'warning' | 'error' | 'info'} severity - MUI Severity, warning, error, or info. Determines color and icon of Alert + * @returns {ReactElement | false} PopupAlert Component + */ +const PopupAlert = ({ + closable = false, + title, + message, + severity, + timeout, +}: PopupAlertProps): ReactElement | false => { + const [closed, setClosed] = useState(false); + const isSmallViewport = useIsSmallViewport(); + + useOnMountEffect(() => { + if (timeout === undefined) return; + + const timer = setTimeout(() => { + setClosed(true); + }, timeout); + + return () => clearTimeout(timer); + }); + const sxProps: SxProps = isSmallViewport + ? { + left: '50%', + transform: 'translate(-50%, 0%)', + // We account for the SmallViewportHeader + top: '64px', + } + : { + left: '0.25rem', + top: '0.25rem', + }; + + return ( + !closed && ( + { + setClosed(true); + }, + } + : {})} + sx={{ + ...sxProps, + position: 'absolute', + width: '100%', + maxWidth: '320px', + }} + className="bg-vera-surface!" + > + {title} + {message} + + ) + ); +}; + +export default PopupAlert; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/index.tsx new file mode 100644 index 00000000..7b53a5b7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupAlert/index.tsx @@ -0,0 +1,3 @@ +import PopupAlert from './PopupAlert'; + +export default PopupAlert; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.spec.tsx new file mode 100644 index 00000000..0e459d39 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.spec.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import PopupDialog, { DialogProps, DialogTexts } from './PopupDialog'; + +describe('PopupDialog', () => { + const mockHandleClose = vi.fn(); + const mockHandleActionClick = vi.fn(); + + const mockDialogText: DialogTexts = { + title: 'Confirmation', + contents: 'Are you sure you want to proceed?', + primaryActionText: 'Confirm', + secondaryActionText: 'Cancel', + }; + + const defaultProps: DialogProps = { + isOpen: true, + handleClose: mockHandleClose, + handleActionClick: mockHandleActionClick, + actionText: mockDialogText, + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the dialog with the correct text', () => { + render(); + expect(screen.getByText('Confirmation')).toBeInTheDocument(); + expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument(); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('does not render the dialog if it has not been opened', () => { + render(); + + expect(screen.queryByText('Confirmation')).not.toBeInTheDocument(); + }); + + it('auto-focuses the Confirm button', () => { + render(); + + const confirmButton = screen.getByText('Confirm'); + expect(confirmButton).toHaveFocus(); + }); + + it('closes the component when the Cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockHandleClose).toHaveBeenCalledTimes(1); + }); + + it('executes the primary action when the Confirm button is clicked', () => { + render(); + + const confirmButton = screen.getByText('Confirm'); + fireEvent.click(confirmButton); + + expect(mockHandleActionClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.tsx new file mode 100644 index 00000000..a80456cb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/PopupDialog.tsx @@ -0,0 +1,62 @@ +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Button from '@mui/material/Button'; +import { ReactElement } from 'react'; + +export type DialogTexts = { + title?: string; + contents: string; + primaryActionText: string; + secondaryActionText: string; +}; + +export type DialogProps = { + isOpen: boolean; + handleClose: () => void; + handleActionClick: () => void; + actionText: DialogTexts; +}; + +/** + * PopupDialog Component + * + * Reusable pop-up dialog component that opens when a certain action is performed such as starting/stopping an archive, + * or muting a participant. + * @param {DialogProps} props - The props for the component. + * @property {boolean} isOpen - Whether the pop-up dialog component is opened. + * @property {() => void} handleClose - Function to close the pop-up dialog component. + * @property {() => void} handleActionClick - Function that handles the action when the primary action button is clicked. + * @property {DialogTexts} actionText - The text that gets displayed in the pop-up component. + * @returns {ReactElement} - The PopupDialog component. + */ +const PopupDialog = ({ + isOpen, + handleClose, + handleActionClick, + actionText, +}: DialogProps): ReactElement => { + return ( + + {actionText.title} + + {actionText.contents} + + + + + + + ); +}; + +export default PopupDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/index.tsx new file mode 100644 index 00000000..128cbf19 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/PopupDialog/index.tsx @@ -0,0 +1,4 @@ +import PopupDialog, { DialogTexts } from './PopupDialog'; + +export default PopupDialog; +export type { DialogTexts }; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.spec.tsx new file mode 100644 index 00000000..d42411a0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.spec.tsx @@ -0,0 +1,32 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { describe, expect, it } from 'vitest'; +import { makeTestProvider } from '@test/providers'; +import RecordingIndicator from './RecordingIndicator'; + +describe('RecordingIndicator', () => { + it('renders the root indicator, pulse, and dot', () => { + render(); + + expect(screen.getByTestId('recordingIndicator')).toBeInTheDocument(); + expect(screen.getByTestId('recordingIndicatorPulse')).toBeInTheDocument(); + expect(screen.getByTestId('recordingIndicatorDot')).toBeInTheDocument(); + }); + + it('uses the compact size when requested', () => { + render(); + + expect(screen.getByTestId('recordingIndicator')).toHaveClass('h-4', 'w-4'); + + expect(screen.getByTestId('recordingIndicatorDot')).toHaveClass('inset-1'); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.tsx new file mode 100644 index 00000000..1d7aedf9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/RecordingIndicator.tsx @@ -0,0 +1,43 @@ +import Box from '@mui/material/Box'; +import classNames from 'classnames'; +import type { ReactElement } from 'react'; + +export type RecordingIndicatorProps = { + isCompact?: boolean; +}; + +const RecordingIndicator = ({ isCompact = false }: RecordingIndicatorProps): ReactElement => { + return ( + + + + + ); +}; + +export default RecordingIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/index.tsx new file mode 100644 index 00000000..302c60a5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingIndicator/index.tsx @@ -0,0 +1,3 @@ +import RecordingIndicator from './RecordingIndicator'; + +export default RecordingIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.spec.tsx new file mode 100644 index 00000000..30d313c4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.spec.tsx @@ -0,0 +1,125 @@ +import { ReactElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render as renderBase, screen, waitFor } from '@testing-library/react'; +import RecordingPopUpIndicator from './RecordingPopupIndicator'; +import { makeTestProvider, providers } from '@test/providers'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; +import jwt from 'jsonwebtoken'; + +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useNavigate: () => mockNavigate, + }; +}); + +const translationsByKey: Record = { + 'recording.consent.dialog.title': 'This meeting is being recorded', + 'recording.consent.dialog.content': + 'The host has started recording this meeting. Do you consent to being recorded?', + 'recording.consent.dialog.accept': 'I consent', + 'recording.consent.dialog.decline': 'I do not consent', + 'recording.consent.goodbye.header': 'Recording declined', + 'recording.consent.goodbye.message': + 'You chose not to consent to recording. Please rejoin when recording is stopped or ask the host to disable recording.', +}; + +vi.mock('react-i18next', () => { + return { + useTranslation: () => ({ + t: (key: string) => translationsByKey[key] ?? key, + }), + }; +}); + +const sessionId = '1_MX4xMjM0NTY3OH4-VGh1IEZlYiAyNyAwODozMjozNCBQU1QgMjAyMH4wLjI0NDYxMjE'; +const sessionKey = jwt.sign({ sessionId, roomName: 'test-room-name' }, 'test', { + algorithm: 'HS256', +}); + +describe('RecordingPopUpIndicator', () => { + beforeEach(() => { + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + permissions: { + query: Promise.resolve({ state: 'granted' } as PermissionStatus), + }, + }); + }); + + it('renders the consent dialog with translated text', () => { + render(); + + expect(screen.getByText(translationsByKey['recording.consent.dialog.title'])).toBeVisible(); + expect(screen.getByText(translationsByKey['recording.consent.dialog.content'])).toBeVisible(); + expect(screen.getByText(translationsByKey['recording.consent.dialog.accept'])).toBeVisible(); + expect(screen.getByText(translationsByKey['recording.consent.dialog.decline'])).toBeVisible(); + }); + + it('redirects to /goodbye when the user declines consent', () => { + render(); + + fireEvent.click(screen.getByText(translationsByKey['recording.consent.dialog.decline'])); + + expect(mockNavigate).toHaveBeenCalledWith( + `/goodbye/${sessionKey}`, + expect.objectContaining({ + state: { + header: translationsByKey['recording.consent.goodbye.header'], + caption: translationsByKey['recording.consent.goodbye.message'], + isSelfDeclinedRecording: true, + }, + }) + ); + }); + + it('closes the dialog without redirect when the user consents', async () => { + render(); + + fireEvent.click(screen.getByTestId('popup-dialog-primary-button')); + + await waitFor(() => { + expect( + screen.queryByText(translationsByKey['recording.consent.dialog.title']) + ).not.toBeInTheDocument(); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +function render(component: ReactElement) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ], + { + sessionContext: { + initialValue: { + sessionKey, + }, + }, + userContext: undefined, + publisherContext: undefined, + backgroundPublisherContext: undefined, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(component, { + wrapper, + }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.tsx new file mode 100644 index 00000000..72e0b83c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/RecordingPopupIndicator.tsx @@ -0,0 +1,70 @@ +import { ReactElement, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import PopupDialog, { type DialogTexts } from '../PopupDialog'; +import useSessionContext from '@hooks/useSessionContext'; + +type RecordingPopUpIndicatorProps = { + shouldPromptRecordingConsent?: boolean; + onNotified?: () => void; +}; + +/** + * RecordingPopUpIndicator component displays a popup dialog to prompt the user for recording consent. + * @param {RecordingPopUpIndicatorProps} props - The props for the component. + * @property {boolean} shouldPromptRecordingConsent - Flag indicating whether to prompt for recording consent. + * @returns ReactElement representing the recording consent popup dialog. + */ +const RecordingPopUpIndicator = ({ + shouldPromptRecordingConsent = false, + onNotified, +}: RecordingPopUpIndicatorProps): ReactElement => { + const { setRecordingAlreadyNotified, sessionKey } = useSessionContext(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [hasBeenNotified, setHasBeenNotified] = useState(false); + + const actionText = useMemo(() => { + return { + title: t('recording.consent.dialog.title'), + contents: t('recording.consent.dialog.content'), + primaryActionText: t('recording.consent.dialog.accept'), + secondaryActionText: t('recording.consent.dialog.decline'), + }; + }, [t]); + + const redirectToGoodbye = () => { + navigate(`/goodbye/${sessionKey}`, { + state: { + header: t('recording.consent.goodbye.header'), + caption: t('recording.consent.goodbye.message'), + isSelfDeclinedRecording: true, + }, + }); + }; + + const handleDecline = () => { + setHasBeenNotified(true); + redirectToGoodbye(); + }; + + const handleActionClick = () => { + setHasBeenNotified(true); + setRecordingAlreadyNotified(true); + onNotified?.(); + }; + + const shouldOpenDialog = shouldPromptRecordingConsent && hasBeenNotified === false; + + return ( + + ); +}; + +export default RecordingPopUpIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/index.tsx new file mode 100644 index 00000000..6b88e9c4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RecordingPopupIndicator/index.tsx @@ -0,0 +1,3 @@ +import RecordingPopUpIndicator from './RecordingPopupIndicator'; + +export default RecordingPopUpIndicator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx new file mode 100644 index 00000000..27ca1669 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.spec.tsx @@ -0,0 +1,156 @@ +import { describe, expect, it, vi, afterEach, Mock, beforeEach } from 'vitest'; +import { fireEvent, render as renderBase, screen, waitFor } from '@testing-library/react'; +import { EventEmitter } from 'stream'; +import { Publisher } from '@vonage/client-sdk-video'; +import type { ReactElement } from 'react'; +import { defaultAudioDevice } from '@utils/mockData/device'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { PublisherContextType } from '@Context/PublisherProvider'; +import { makeTestProvider } from '@test/providers'; +import { makeMediaDeviceInfos } from '@web-test/fixtures'; +import { mediaDevices$ } from '@core/stores'; +import ReduceNoiseTestSpeakers from './ReduceNoiseTestSpeakers'; +import { env } from '../../../env'; + +vi.mock('@hooks/usePublisherContext'); + +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; + +const { mockHasMediaProcessorSupport } = vi.hoisted(() => { + return { mockHasMediaProcessorSupport: vi.fn().mockReturnValue(true) }; +}); +vi.mock('@vonage/client-sdk-video', () => ({ + hasMediaProcessorSupport: mockHasMediaProcessorSupport, +})); + +describe('ReduceNoiseTestSpeakers', () => { + let mockPublisher: Publisher; + let publisherContext: PublisherContextType; + + beforeEach(() => { + mediaDevices$.reset(); + + // Mock HTMLMediaElement methods used by SoundTest component + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {}); + + mockPublisher = Object.assign(new EventEmitter(), { + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + applyAudioFilter: vi.fn(), + clearAudioFilter: vi.fn(), + getAudioSource: () => defaultAudioDevice, + getAudioFilter: () => null, + videoWidth: () => 1280, + videoHeight: () => 720, + }) as unknown as Publisher; + publisherContext = { + publisher: mockPublisher, + isPublishing: true, + publish: vi.fn() as () => Promise, + initializeLocalPublisher: vi.fn(() => { + publisherContext.publisher = mockPublisher; + }) as unknown as () => void, + } as unknown as PublisherContextType; + + mockUsePublisherContext.mockImplementation(() => publisherContext); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('renders the component with the correct elements', () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + + render(); + + expect(screen.getByText('Advanced Noise Suppression')).toBeInTheDocument(); + expect(screen.getByLabelText('Advanced Noise Suppression')).not.toBeChecked(); + }); + + it('does not render the component if media processor is not supported', () => { + mockHasMediaProcessorSupport.mockReturnValue(false); + + render(); + + expect(screen.queryByText('Advanced Noise Suppression')).not.toBeInTheDocument(); + }); + + it('toggles the noise suppression state when clicked', async () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + + render(); + + const toggleButton = screen.getByLabelText('Advanced Noise Suppression'); + fireEvent.click(toggleButton); + + await waitFor(() => expect(mockPublisher.applyAudioFilter).toHaveBeenCalledTimes(1)); + expect(mockPublisher.applyAudioFilter).toHaveBeenCalledWith({ + type: 'advancedNoiseSuppression', + }); + expect(toggleButton).toBeChecked(); + + fireEvent.click(toggleButton); + await waitFor(() => expect(mockPublisher.clearAudioFilter).toHaveBeenCalledTimes(1)); + expect(toggleButton).not.toBeChecked(); + }); + + it('should update the UI when toggling the button', async () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + + render(); + + const toggleButton = screen.getByLabelText('Advanced Noise Suppression'); + + expect(toggleButton).not.toBeChecked(); + + fireEvent.click(toggleButton); + await waitFor(() => { + expect(toggleButton).toBeChecked(); + }); + + fireEvent.click(toggleButton); + await waitFor(() => { + expect(toggleButton).not.toBeChecked(); + }); + }); + + it('does not render the Advanced Noise Suppression option if allowAdvancedNoiseSuppression is false', () => { + env.partialUpdate({ + ALLOW_ADVANCED_NOISE_SUPPRESSION: false, + }); + + render(); + + expect(screen.queryByText('Advanced Noise Suppression')).not.toBeInTheDocument(); + }); + + it('does not render the SoundTest if no audiooutput devices are available', () => { + mediaDevices$.reset(); + + render(); + + expect(screen.queryByTestId('soundTest')).not.toBeInTheDocument(); + }); + + it('renders the SoundTest if audiooutput devices are available', () => { + const devices = makeMediaDeviceInfos(); + + mediaDevices$.setState((state) => ({ ...state, mediaDeviceInfo: devices })); + + render(); + + expect(screen.getByTestId('soundTest')).toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx new file mode 100644 index 00000000..bcfc6754 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect, type ReactElement } from 'react'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; +import { mediaDevices$ } from '@core/stores'; +import SwitchField from '@ui/SwitchField'; +import DropdownSeparator from '../DropdownSeparator'; +import SoundTest from '../../SoundTest'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +import { env } from '../../../env'; + +/** + * ReduceNoiseTestSpeakers Component + * + * This component displays options to enable advanced noise suppression + * and to test the speakers. + * @returns {ReactElement | false} Returns ReduceNoiseTestSpeakers component or false if the Vonage Media Processor is not supported. + */ +const ReduceNoiseTestSpeakers = (): ReactElement | false => { + const { t } = useTranslation(); + const { publisher, isPublishing } = usePublisherContext(); + + const [isToggled, setIsToggled] = useState(false); + const shouldDisplayANS = hasMediaProcessorSupport('both') && env.ALLOW_ADVANCED_NOISE_SUPPRESSION; + const hasSpeakerDevices = mediaDevices$.useMediaDevices( + 'audiooutput', + (devices) => Object.values(devices).length > 0 + ); + + const handleNoiseSuppressionChange = async (checked: boolean) => { + setIsToggled(checked); + setStorageItem(STORAGE_KEYS.NOISE_SUPPRESSION, JSON.stringify(checked)); + + if (checked) { + await publisher?.applyAudioFilter({ type: 'advancedNoiseSuppression' }); + return; + } + + await publisher?.clearAudioFilter(); + }; + + useEffect(() => { + if (isPublishing) { + const audioFilter = publisher?.getAudioFilter(); + + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsToggled(audioFilter !== null); + } + }, [isPublishing, publisher]); + + return ( + <> + + + {shouldDisplayANS && ( + +
        + + + +
        + +
        +
        +
        + )} + {hasSpeakerDevices && ( + + + + + + )} +
        + + ); +}; + +export default ReduceNoiseTestSpeakers; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/index.tsx new file mode 100644 index 00000000..ab38b793 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/index.tsx @@ -0,0 +1,3 @@ +import ReduceNoiseTestSpeakers from './ReduceNoiseTestSpeakers'; + +export default ReduceNoiseTestSpeakers; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.spec.tsx new file mode 100644 index 00000000..5f75fecf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.spec.tsx @@ -0,0 +1,260 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import FeedbackForm from './FeedbackForm'; +import { + REPORT_NAME_LIMIT, + REPORT_DESCRIPTION_LIMIT, + REPORT_TITLE_LIMIT, +} from '../../../../utils/constants'; + +describe('FeedbackForm', () => { + const mockHandleSubmit = vi.fn(); + const mockHandleChange = vi.fn(); + const mockFileSelect = vi.fn(); + const formData = { + title: '', + name: '', + issue: '', + }; + const errors = { + title: false, + name: false, + issue: false, + }; + + const defaultProps = { + handleSubmit: mockHandleSubmit, + formData, + errors, + handleChange: mockHandleChange, + loading: false, + onFileSelect: mockFileSelect, + }; + + it('renders form fields correctly', () => { + render(); + + // Check if the title input is rendered + expect(screen.queryByTestId('title-report-issue')).toBeInTheDocument(); + + // Check if the name input is rendered + expect(screen.queryByTestId('name-report-issue')).toBeInTheDocument(); + + // Check if the issue textarea is rendered + expect(screen.queryByTestId('description-report-issue')).toBeInTheDocument(); + + // Check if the button is rendered + expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument(); + }); + + it('shows required error messages when form inputs are empty', () => { + const errorMessages = { + title: true, + name: true, + issue: true, + }; + + const emptyFormProps = { + ...defaultProps, + errors: errorMessages, + }; + + render(); + + // Check if error messages are shown + expect( + screen.getByText(`Title is required and must be less than ${REPORT_TITLE_LIMIT} characters`) + ).toBeInTheDocument(); + expect( + screen.getByText( + `Your name is required and must be less than ${REPORT_NAME_LIMIT} characters` + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + `Description is required and must be less than ${REPORT_DESCRIPTION_LIMIT} characters` + ) + ).toBeInTheDocument(); + }); + + it('displays a loading spinner when loading is true', () => { + const spinnerProps = { + ...defaultProps, + loading: true, + }; + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('displays helper text for character limit when exceeding limit for the form title', () => { + const charLimitProps = { + ...defaultProps, + formData: { + title: 'a'.repeat(REPORT_TITLE_LIMIT - 1), + name: '', + issue: '', + }, + errors: { + title: false, + name: false, + issue: false, + }, + }; + const { rerender } = render(); + const errorSpan = screen.getByTestId('title-error'); + // Tests that one-less than the max does not cause an error + expect(errorSpan).toBeEmptyDOMElement(); + rerender( + + ); + // Test that the max does cause an error + expect(errorSpan).toBeInTheDocument(); + expect(errorSpan).toHaveTextContent( + `Title is required and must be less than ${REPORT_TITLE_LIMIT} characters` + ); + rerender( + + ); + // Tests that changing the string to less than the max makes the previous error message go away + expect(errorSpan).toBeEmptyDOMElement(); + }); + + it('displays helper text for character limit when exceeding limit for the form name', () => { + const charLimitProps = { + ...defaultProps, + formData: { + title: '', + name: 'a'.repeat(REPORT_NAME_LIMIT - 1), + issue: '', + }, + errors: { + title: false, + name: false, + issue: false, + }, + }; + const { rerender } = render(); + const errorSpan = screen.getByTestId('name-error'); + // Tests that one-less than the max does not cause an error + expect(errorSpan).toBeEmptyDOMElement(); + rerender( + + ); + // Test that the max does cause an error + expect(errorSpan).toBeInTheDocument(); + expect(errorSpan).toHaveTextContent( + `Your name is required and must be less than ${REPORT_NAME_LIMIT} characters` + ); + rerender( + + ); + // Tests that changing the string to less than the max makes the previous error message go away + expect(errorSpan).toBeEmptyDOMElement(); + }); + + it('displays helper text for character limit when exceeding limit for the form description', () => { + const charLimitProps = { + ...defaultProps, + formData: { + title: '', + name: '', + issue: 'a'.repeat(REPORT_DESCRIPTION_LIMIT - 1), + }, + errors: { + title: false, + name: false, + issue: false, + }, + }; + const { rerender } = render(); + const errorSpan = screen.getByTestId('description-error'); + // Tests that one-less than the max does not cause an error + expect(errorSpan).toBeEmptyDOMElement(); + rerender( + + ); + // Test that the max does cause an error + expect(errorSpan).toBeInTheDocument(); + expect(errorSpan).toHaveTextContent( + `Description is required and must be less than ${REPORT_DESCRIPTION_LIMIT} characters` + ); + rerender( + + ); + // Tests that changing the string to less than the max makes the previous error message go away + expect(errorSpan).toBeEmptyDOMElement(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx new file mode 100644 index 00000000..5357692c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx @@ -0,0 +1,238 @@ +import { FormEvent, ChangeEvent, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import FilePicker from './FilePicker'; +import { + REPORT_TITLE_LIMIT, + REPORT_DESCRIPTION_LIMIT, + REPORT_NAME_LIMIT, +} from '../../../../utils/constants'; +import HelperText from './HelperText'; +import useIsSmallViewport from '../../../../hooks/useIsSmallViewport'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; + +export type FormType = { + title: string; + name: string; + issue: string; +}; + +export type ErrorFormType = { + title: boolean; + name: boolean; + issue: boolean; +}; + +export type FeedbackFormType = { + handleSubmit: (event: FormEvent) => Promise; + formData: FormType; + errors: ErrorFormType; + handleChange: (event: ChangeEvent) => void; + loading: boolean; + onFileSelect: (fileData: string) => void; +}; + +const getStyleTypography = () => { + return { + marginBottom: '4px', + textAlign: 'left', + }; +}; + +/** + * FeedbackForm Component + * + * This component contains a form that allows the user to add a title, their name, a short description of the issue + * as well as attach a screenshot for the issue they have encountered while using the application. + * @param {FeedbackFormType} props - the props for the component. + * @property {(event: FormEvent) => Promise} handleSubmit - the function that handles form submission. + * @property {FormType} formData - the field containing the values from the form. + * @property {ErrorFormType} errors - the field containing the errors while user puts in the values in the form. + * @property {(event: ChangeEvent) => void} handleChange - the function that handles validating users' input. + * @property {boolean} loading - indicates whether the form is currently loading. + * @property {(fileData: string) => void} onFileSelect - the function that handles storing the file value. + * @returns {ReactElement} The feedback form component. + */ +const FeedbackForm = ({ + handleSubmit, + formData, + errors, + handleChange, + loading, + onFileSelect, +}: FeedbackFormType): ReactElement => { + const { t } = useTranslation(); + const isSmallViewport = useIsSmallViewport(); + // 224px = 64px panel header + 96px toolbar if normal viewport + (40px submit button + 24px submit button margin) + // 208px = 64px panel header + 80px toolbar if small viewport + (40px submit button + 24px submit button margin) + const height = isSmallViewport ? 'calc(100dvh - 208px)' : 'calc(100dvh - 224px)'; + // For small viewports: width = 100dvw - 50px of margin + // For desktop viewports: 310px = 350px RightPanel - 50px margin + const width = isSmallViewport ? 'calc(100dvw - 50px)' : '300px'; + + const getColorStyle = (value: string, maxLength: number) => { + return value.length >= maxLength || value.length === 0 ? 'var(--vera-error)' : 'inherit'; + }; + + return loading ? ( + + + + ) : ( +
        + + + {t('feedbackForm.field.title.label')} + + + } + sx={{ + mb: '22px', + }} + slotProps={{ htmlInput: { maxLength: REPORT_TITLE_LIMIT } }} + /> + + {t('feedbackForm.field.name.label')} + + + } + sx={{ + mr: 4, + mb: 2, + }} + slotProps={{ htmlInput: { maxLength: REPORT_NAME_LIMIT } }} + /> + + + {t('feedbackForm.field.issue.label')} + + + + } + sx={{ + mr: 4, + mb: 1, + }} + slotProps={{ htmlInput: { maxLength: REPORT_DESCRIPTION_LIMIT } }} + /> + + + {t('feedbackForm.disclaiamer.label')} + + + {t('feedbackForm.disclaiamer.screenshot')} + + + + + +
        + ); +}; + +export default FeedbackForm; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.spec.tsx new file mode 100644 index 00000000..d17503d3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.spec.tsx @@ -0,0 +1,127 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import FilePicker from './FilePicker'; +import { isMobile } from '@web/platform'; +import '@testing-library/jest-dom'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@web/platform'); + +describe('FilePicker component', () => { + beforeEach(() => { + setupWindowNavigatorMock(); + }); + + const mockFileSelect = vi.fn(); + + it('renders the "Add screenshot" button initially', () => { + render(); + const addButton = screen.getByText(/add screenshot/i); + expect(addButton).toBeInTheDocument(); + }); + + describe('"Capture screenshot" button', () => { + it('is rendered on desktop devices', () => { + render(); + const addButton = screen.getByText(/capture screenshot/i); + expect(addButton).toBeInTheDocument(); + }); + + it('is not rendered on mobile devices', () => { + vi.mocked(isMobile).mockImplementation(() => true); + + const addButton = screen.queryByText(/capture screenshot/i); + expect(addButton).not.toBeInTheDocument(); + }); + }); + + it('uploads and previews a valid image file', async () => { + render(); + + const input = screen.getByLabelText(/add screenshot/i); // File input + + // Create a mock image file + const file = new File(['dummy content'], 'example.png', { type: 'image/png' }); + + // Simulate file upload + fireEvent.change(input, { target: { files: [file] } }); + + // Assert that the image preview appears + const image = await screen.findByAltText('screenshot'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', expect.stringContaining('data:image/png')); // Check that the src is a data URL + + // Check that the parent component has been notified of changes + const imageSrc = image.getAttribute('src'); + expect(mockFileSelect).toHaveBeenCalledWith(imageSrc); + }); + + it('shows an error if the file is too large', async () => { + render(); + + const input = screen.getByLabelText(/add screenshot/i); // File input + + // Create a mock image file + const file = new File(['dummy content'], 'example.png', { type: 'image/png' }); + + // Mock the file's size to simulate an oversized file + Object.defineProperty(file, 'size', { value: 21 * 1024 * 1024 }); // 21MB + + // Simulate file upload + fireEvent.change(input, { target: { files: [file] } }); + + // Expect an error message to be displayed for a file too large + expect(await screen.findByText(/The maximum upload size is 20MB/i)).toBeInTheDocument(); + }); + + it('takes a screenshot when Capture screenshot button is clicked', async () => { + const MockMediaStream = vi.fn().mockImplementation(() => ({ + active: true, + getTracks: () => [], + })); + + const getDisplayMediaMock = vi.fn(); + vi.spyOn(navigator.mediaDevices, 'getDisplayMedia').mockImplementation(getDisplayMediaMock); + + const mockStream = new MockMediaStream(); + getDisplayMediaMock.mockResolvedValue(mockStream); + + // Mocking the play method + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + + // Mock canvas context and drawing operations + const mockContext = { + drawImage: vi.fn(), + }; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + mockContext as unknown as CanvasRenderingContext2D + ); + + // Stub the toDataURL function of HTMLCanvasElement + vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( + 'data:image/png;base64,fakebase64data' + ); + + render(); + + const button = screen.getByRole('button', { name: /capture screenshot/i }); + fireEvent.click(button); + + const expectedDisplayMediaOptions = { + video: { + displaySurface: 'tab', + }, + audio: false, + preferCurrentTab: true, + }; + + expect(getDisplayMediaMock).toHaveBeenCalledWith(expectedDisplayMediaOptions); + + const image = await screen.findByAltText('screenshot'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', expect.stringContaining('data:image/png')); + + const imageSrc = image.getAttribute('src'); + expect(mockFileSelect).toHaveBeenCalledWith(imageSrc); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx new file mode 100644 index 00000000..73e534d7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx @@ -0,0 +1,182 @@ +import { ChangeEvent, useRef, useState, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import captureScreenshot from '../../../../../utils/captureScreenshot'; +import { isMobile } from '@web/platform'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import VividIcon from '@ui/VividIcon'; + +// Setting the maximum file size to 20MB +const maxFileSize = 2e7; + +/** + * FilePicker Component + * + * This component allows users to upload an image, preview it, and delete the uploaded image. + * The component validates file size, displays a preview for supported images, and includes a delete button. + * @param {(fileData: string) => void} onFileSelect - the function that handles storing the file value. + * @returns {ReactElement} The exit button component + */ +const FilePicker = ({ + onFileSelect, +}: { + onFileSelect: (fileData: string) => void; +}): ReactElement => { + const { t } = useTranslation(); + const [imageSrc, setImageSrc] = useState(''); + const imageRef = useRef(null); + const [maximumSizeError, setMaximumSizeError] = useState(false); + + const checkIfSizeAllowed = (file: File) => { + if (file.size > maxFileSize) { + setMaximumSizeError(true); + return false; + } + return true; + }; + + /** + * Clears the uploaded image preview by setting `imageSrc` to `null`. + */ + const handleDeleteFile = () => { + setImageSrc(''); + }; + + /** + * Handles file upload and validates the file type. + * If the file size is allowed, reads the file and sets it as the image preview. + * @param {ChangeEvent} event - The file input change event + */ + const handleFileUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0] || null; + if (file && checkIfSizeAllowed(file)) { + setMaximumSizeError(false); + const reader = new FileReader(); + reader.onload = (e) => { + const fileData = e.target?.result as string; + setImageSrc(fileData); + onFileSelect(fileData); + }; + reader.readAsDataURL(file); + } + }; + + const processScreenshot = async () => { + try { + const screenshotData = await captureScreenshot(); + setImageSrc(screenshotData); + onFileSelect(screenshotData); + } catch (error: unknown) { + if (error instanceof Error) { + console.error(error.message); + } + } + }; + return ( + <> + {imageSrc && ( + + {t('filePicker.attachedScreenshot')} + + )} + + {maximumSizeError && ( + + {t('filePicker.sizeLimit')} + + )} + {!imageSrc ? ( + <> + {!isMobile() && ( + // The screenshot capture relies on the getDisplayMedia browser API which is unsupported on mobile devices + // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#browser_compatibility + + )} + + + ) : ( + + + + + + + + + + + )} + + + ); +}; + +export default FilePicker; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/index.tsx new file mode 100644 index 00000000..e6a188ce --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/index.tsx @@ -0,0 +1,3 @@ +import FilePicker from './FilePicker'; + +export default FilePicker; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.spec.tsx new file mode 100644 index 00000000..e5857cf4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.spec.tsx @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import HelperText from './HelperText'; + +describe('HelperText', () => { + let isError: boolean; + let errorType: 'Title' | 'Your name' | 'Description'; + let colorStyle: 'inherit' | 'red'; + let textLimit: number; + let formText: string; + let testId: string; + + beforeEach(() => { + isError = false; + errorType = 'Title'; + colorStyle = 'inherit'; + textLimit = 5; + formText = 'Your title is wrong and your feet smell :^)'; + testId = 'title-error'; + }); + + it('if there is no error, displays no error text', () => { + render( + + ); + + expect(screen.getByTestId(testId).innerHTML).toEqual(''); + }); + + it('if there is an error, displays error text', () => { + isError = true; + const expectedErrorText = `${errorType} is required and must be less than ${textLimit} characters`; + + render( + + ); + + expect(screen.getByTestId(testId).innerHTML).toEqual(expectedErrorText); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.tsx new file mode 100644 index 00000000..4d67309c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/HelperText.tsx @@ -0,0 +1,64 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; + +export type HelperTextProps = { + isError: boolean; + errorType: string; + colorStyle: string; + textLimit: number; + formText: string; + testId: string; +}; + +/** + * HelperText Component + * + * Renders the helper text for a given field. If there is an error, displays instructions to + * rectify it. + * @param {HelperTextProps} props - The props for the component. + * @property {boolean} isError - Whether to display the error or not. + * @property {string} errorType - What kind of error this is. + * @property {string} colorStyle - Whether to use plain or red text. + * @property {number} textLimit - How many characters can exist for the form type. + * @property {string} formText - What is present in the form's field. + * @property {string} testId - The testId to assign to the error name + * @returns {ReactElement} The HelperText component + */ +const HelperText = ({ + errorType, + colorStyle, + isError, + textLimit, + formText, + testId, +}: HelperTextProps): ReactElement => { + const { t } = useTranslation(); + const errorName = (field: string, limit: number) => { + return t('feedbackForm.helperText.error', { field, limit }); + }; + + return ( + + + {isError ? errorName(errorType, textLimit) : ''} + + + {!isError ? `${formText.length}/${textLimit}` : ''} + + + ); +}; + +export default HelperText; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/index.tsx new file mode 100644 index 00000000..b68a3e6a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/HelperText/index.tsx @@ -0,0 +1,3 @@ +import HelperText from './HelperText'; + +export default HelperText; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/index.tsx new file mode 100644 index 00000000..94b9ae2a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/index.tsx @@ -0,0 +1,3 @@ +import FeedbackForm from './FeedbackForm'; + +export default FeedbackForm; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.spec.tsx new file mode 100644 index 00000000..8ef8bd3b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.spec.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import FormSubmitted from './FormSubmitted'; + +describe('FormSubmitted', () => { + it('should display a success message with the ticket number and track progress link', () => { + // Mock the handleCloseFormSubmitted function + const mockHandleClose = vi.fn(); + + const mockTicketResponse = { + message: 'Your issue has been submitted successfully!', + ticketUrl: 'http://example.com', + }; + + // Render the component with mock props + render( + + ); + + // Check if the success message and ticket number are displayed + expect(screen.queryByTestId('thank-you-text')).toBeInTheDocument(); + expect(screen.queryByTestId('track-progress-text')).toBeInTheDocument(); + + // Check if the link is correct + const link = screen.queryByTestId('track-progress-text'); + expect(link).toHaveAttribute('href', mockTicketResponse.ticketUrl); + }); + + it('should display an error message when there is no ticket number', () => { + // Mock the handleCloseFormSubmitted function + const mockHandleClose = vi.fn(); + + const mockTicketResponse = { + message: 'There was an error submitting your feedback.', + ticketUrl: '', + }; + + // Render the component with mock props + render( + + ); + + // Check if the error message is displayed + expect(screen.queryByTestId('error-text')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.tsx new file mode 100644 index 00000000..a1937c9a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/FormSubmitted.tsx @@ -0,0 +1,124 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; + +export type TicketResponseType = { + message: string; + ticketUrl: string; +}; + +export type FormSubmittedProps = { + handleCloseFormSubmitted: () => void; + ticketResponse: TicketResponseType; +}; + +/** + * FormSubmitted Component + * + * This component displays either a success message upon form submission, or an error if there was one encountered. + * @param {FormSubmittedProps} props - the props for the component. + * @property {() => void} handleCloseFormSubmitted - the function handling closing component upon form submission. + * @property {TicketResponseType} ticketResponse - the field containing response from the backend. + * @returns {ReactElement} The form submitted component. + */ + +const FormSubmitted = ({ + handleCloseFormSubmitted, + ticketResponse, +}: FormSubmittedProps): ReactElement => { + const { t } = useTranslation(); + + return ( + <> + + + {ticketResponse.ticketUrl ? ( + + + {t('feedbackForm.submitted.thanks')} + + + {t('feedbackForm.submitted.content')} + + + {ticketResponse.message} + + + {t('feedbackForm.submitted.track')} + + + ) : ( + + {t('feedbackForm.submitted.error')} + + )} + + + + + + + ); +}; + +export default FormSubmitted; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/index.tsx new file mode 100644 index 00000000..4231d272 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/FormSubmitted/index.tsx @@ -0,0 +1,3 @@ +import FormSubmitted from './FormSubmitted'; + +export default FormSubmitted; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.spec.tsx new file mode 100644 index 00000000..95c2fc79 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, beforeEach, vi, expect, Mock } from 'vitest'; +import ReportIssue from './ReportIssue'; + +import reportIssue from '../../../api/reportFeedback'; + +// Mock +vi.mock('../../../hooks/useCollectBrowserInformation', () => ({ + default: () => ({ browser: 'TestBrowser' }), +})); +vi.mock('../../../api/reportFeedback', () => ({ + default: vi.fn(), +})); + +const mockHandleClose = vi.fn(); + +describe('ReportIssue component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when isOpen is false', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders form and submits data successfully', async () => { + (reportIssue as Mock).mockResolvedValue({ + data: { + feedbackData: { + message: 'It worked!', + ticketUrl: 'https://example.com', + }, + }, + }); + + render(); + + const input = screen.getByLabelText(/add screenshot/i); // File input + // Create a mock image file + const file = new File(['dummy content'], 'example.png', { type: 'image/png' }); + // Simulate file upload + fireEvent.change(input, { target: { files: [file] } }); + + fireEvent.change(screen.getByTestId('title-input').querySelector('input')!, { + target: { value: 'Trying to submit a form' }, + }); + fireEvent.change(screen.getByTestId('name-input').querySelector('input')!, { + target: { value: 'Jane Doe' }, + }); + fireEvent.change(screen.getByTestId('issue-input').querySelector('textarea')!, { + target: { value: 'App crashed after clicking submit' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /Send/i })); + + const successMessage = await screen.findByText('Thank you for your feedback!'); + expect(successMessage).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('close-button-form-submitted')); + }); + + it('displays error message when submission fails', async () => { + (reportIssue as Mock).mockRejectedValue(new Error('Network error')); + + render(); + + fireEvent.change(screen.getByTestId('title-input').querySelector('input')!, { + target: { value: 'Trying to submit a form' }, + }); + fireEvent.change(screen.getByTestId('name-input').querySelector('input')!, { + target: { value: 'Jane Doe' }, + }); + fireEvent.change(screen.getByTestId('issue-input').querySelector('textarea')!, { + target: { value: 'App crashed after clicking submit' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /Send/i })); + + const successMessage = await screen.findAllByTestId('error-text'); + expect(successMessage[0]).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx new file mode 100644 index 00000000..4072b334 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx @@ -0,0 +1,176 @@ +import { ChangeEvent, ReactElement, useState, FormEvent } from 'react'; +import { AxiosError } from 'axios'; +import { useTranslation } from 'react-i18next'; +import RightPanelTitle from '../RightPanel/RightPanelTitle'; +import useCollectBrowserInformation from '../../../hooks/useCollectBrowserInformation'; +import FormSubmitted from './FormSubmitted'; +import FeedbackForm from './FeedbackForm'; +import reportIssue from '../../../api/reportFeedback'; +import { + REPORT_TITLE_LIMIT, + REPORT_NAME_LIMIT, + REPORT_DESCRIPTION_LIMIT, +} from '../../../utils/constants'; +import { ErrorFormType } from './FeedbackForm/FeedbackForm'; + +export type ReportIssueProps = { + handleClose: () => void; + isOpen: boolean; +}; + +type FormField = 'title' | 'name' | 'issue'; + +export type FormType = { + title: string; + name: string; + issue: string; + attachment: string; +}; + +type ResponseType = { + message: string; + ticketUrl: string; +}; + +/** + * ReportIssue Component + * + * This component allows users to provide feedback about any potential issue they encountered + * @param {ReportIssueProps} props - the props for the component. + * @property {() => void} handleClose - the function handling the closing of the component. + * @property {boolean} isOpen - indicates whether the report issue component is open or closed. + * @returns {ReactElement} The report issue component. + */ +const ReportIssue = ({ handleClose, isOpen }: ReportIssueProps): ReactElement | false => { + const { t } = useTranslation(); + const [formData, setFormData] = useState({ + title: '', + name: '', + issue: '', + attachment: '', + }); + + const [ticketResponse, setTicketResponse] = useState({ + message: '', + ticketUrl: '', + }); + const [isFormVisible, setIsFormVisible] = useState(true); + + const [loading, setLoading] = useState(false); + + const [errors, setErrors] = useState({ + title: false, + name: false, + issue: false, + }); + + const browserInfo = useCollectBrowserInformation(); + + const handleChange = (event: ChangeEvent) => { + const { name, value } = event.target; + + const formFields = name as FormField; + + const newErrors: ErrorFormType = { + title: value.length >= REPORT_TITLE_LIMIT, + name: value.length >= REPORT_NAME_LIMIT, + issue: value.length >= REPORT_DESCRIPTION_LIMIT, + }; + + setFormData((prevFormData) => ({ + ...prevFormData, + [formFields]: value, + })); + + setErrors((prevErrors) => ({ + ...prevErrors, + [name]: value.length === 0 || newErrors[formFields], + })); + }; + + const handleFileSelect = (attachment: string) => { + // we have to remove the data:image/png;base64, part + // because while it provides information about the file type + // it is not part of the actual binary content of the image + const data = attachment.replace(/^data:image\/\w+;base64,/, ''); + setFormData((prev) => ({ ...prev, attachment: data })); + }; + + const handleCloseFormSubmitted = () => { + setFormData({ + title: '', + name: '', + issue: '', + attachment: '', + }); + // this is needed to make the form available the next time it is opened + setIsFormVisible(true); + handleClose(); + }; + + const validateForm = () => { + const newErrors = { + title: formData.title === '', + name: formData.name === '', + issue: formData.issue === '', + }; + setErrors(newErrors); + return !newErrors.issue && !newErrors.title && !newErrors.name; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (validateForm()) { + setLoading(true); + // Add-on the additional information to the issue field + const updatedIssueField = `${formData.issue}\n\nAdditional information:\n${JSON.stringify(browserInfo, null, 2)}`; + + const submissionData = { + ...formData, + issue: updatedIssueField, + }; + try { + // make the axios request + const response = await reportIssue(submissionData); + setTicketResponse(response.data.feedbackData); + setIsFormVisible(false); + setLoading(false); + } catch (error: unknown) { + const axiosError = error as AxiosError; + const errorResponse = { + message: axiosError.message, + ticketUrl: '', + }; + setTicketResponse(errorResponse); + setIsFormVisible(false); + setLoading(false); + } + } + }; + + return ( + isOpen && ( + <> + + {isFormVisible ? ( + + ) : ( + + )} + + ) + ); +}; + +export default ReportIssue; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/index.tsx new file mode 100644 index 00000000..72022d1d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssue/index.tsx @@ -0,0 +1,3 @@ +import ReportIssue from './ReportIssue'; + +export default ReportIssue; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.spec.tsx new file mode 100644 index 00000000..caeb883e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.spec.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import ReportIssueButton from './ReportIssueButton'; + +describe('ReportIssueButton', () => { + it('should invoke callback on click', () => { + const handleClick = vi.fn(); + render(); + screen.getByRole('button').click(); + expect(handleClick).toHaveBeenCalled(); + }); + it('should have a tooltip title that indicates that chat can be opened', async () => { + render( {}} isOpen={false} />); + const button = await screen.findByRole('button'); + await userEvent.hover(button); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Open report issue form'); + }); + it('should have a tooltip title that indicates that chat can be closed', async () => { + render( {}} isOpen />); + const button = await screen.findByRole('button'); + await userEvent.hover(button); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Close report issue form'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.tsx new file mode 100644 index 00000000..85feaf92 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/ReportIssueButton.tsx @@ -0,0 +1,57 @@ +import Tooltip from '@mui/material/Tooltip'; +import { ReactElement, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import ToolbarButton from '../ToolbarButton'; +import VividIcon from '@ui/VividIcon'; + +export type ReportIssueButtonProps = { + handleClick: () => void; + isOpen: boolean; + isOverflowButton?: boolean; +}; + +/** + * ReportIssueButton Component + * + * Displays a clickable button to open/close the ReportIssue panel. + * @param {ReportIssueButtonProps} props - The props for the component. + * @property {Function} handleClick - click handler to open the Report Issue panel + * @property {boolean} isOpen - whether the Report Issue panel is open + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @returns {ReactElement} The ReportIssueButton component. + */ +const ReportIssueButton = ({ + handleClick, + isOpen, + isOverflowButton = false, +}: ReportIssueButtonProps): ReactElement => { + const { t } = useTranslation(); + const anchorRef = useRef(null); + + return ( + + + } + ref={anchorRef} + isOverflowButton={isOverflowButton} + /> + + ); +}; + +export default ReportIssueButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/index.tsx new file mode 100644 index 00000000..3a9a2b2f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ReportIssueButton/index.tsx @@ -0,0 +1,3 @@ +import ReportIssueButton from './ReportIssueButton'; + +export default ReportIssueButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx new file mode 100644 index 00000000..11630be2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanel.tsx @@ -0,0 +1,67 @@ +import { ReactElement } from 'react'; +import ParticipantList from '../ParticipantList/ParticipantList'; +import Chat from '../Chat'; +import ReportIssue from '../ReportIssue'; +import type { RightPanelActiveTab } from '../../../hooks/useRightPanel'; +import BackgroundEffectsLayout from '../../BackgroundEffects/BackgroundEffectsLayout'; +import Box from '@mui/material/Box'; +import type { BoxProps } from '@mui/material/Box'; +import classNames from 'classnames'; +import { twMerge } from 'tailwind-merge'; + +export type RightPanelProps = { + handleClose: () => void; + activeTab: RightPanelActiveTab; +} & BoxProps; + +/** + * RightPanel Component + * Renders a tab panel that enters from off screen on the right of the window. + * The panel displays participant list, chat tab, report issue and background effects. + * @param {RightPanelProps} props - props for the component + * @property {RightPanelActiveTab} activeTab - string indicating which tab to display, or 'closed' if closed + * @property {Function} handleClose - click handler to close the panel + * @returns {ReactElement} RightPanel Component + */ +const RightPanel = ({ + handleClose, + activeTab, + className, + ...boxProps +}: RightPanelProps): ReactElement => { + return ( + + + + + + + ); +}; + +export default RightPanel; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx new file mode 100644 index 00000000..ef0554ea --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/RightPanelTitle.tsx @@ -0,0 +1,51 @@ +import IconButton from '@mui/material/IconButton'; +import { ReactElement } from 'react'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +export type RightPanelTitleProps = { + handleClose: () => void; + title: string; +}; + +/** + * RightPanelTitle component + * Renders the title for the RightPanel component with a text title and an X icon to close the panel + * @param {RightPanelTitleProps} props - the Props + * @property {() => void} handleClose - click handler for X icon + * @property {string} title - Title to display + * } + * @returns {ReactElement} - RightPanelTitle component + */ +const RightPanelTitle = ({ handleClose, title }: RightPanelTitleProps): ReactElement => { + return ( + + + {title} + + + + + + ); +}; + +export default RightPanelTitle; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/index.tsx new file mode 100644 index 00000000..b2190ba0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/RightPanel/index.tsx @@ -0,0 +1,3 @@ +import RightPanel from './RightPanel'; + +export default RightPanel; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.spec.tsx new file mode 100644 index 00000000..b50bba27 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.spec.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Publisher } from '@vonage/client-sdk-video'; +import { EventEmitter } from 'stream'; +import ScreenSharePublisher from './ScreenSharePublisher'; +import { defaultAudioDevice } from '../../../utils/mockData/device'; + +describe('ScreenSharePublisher component', () => { + it('renders nothing if box is undefined', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders VideoTile with ScreenShareNameDisplay and appends element with classes', () => { + const box = { height: 100, width: 100, top: 0, left: 0 }; + const mockPublisher = Object.assign(new EventEmitter(), { + applyVideoFilter: vi.fn(), + clearVideoFilter: vi.fn(), + applyAudioFilter: vi.fn(), + clearAudioFilter: vi.fn(), + getAudioSource: () => defaultAudioDevice, + getAudioFilter: () => null, + videoWidth: () => 1280, + videoHeight: () => 720, + stream: { name: 'Test Stream' }, + }) as unknown as Publisher; + + // Create a dummy element to append (simulate HTMLElement) + const element = document.createElement('div'); + element.className = 'original-class'; + + render( + + ); + + expect(screen.getByText('Test Stream')).toBeInTheDocument(); + expect(element.style.width).toBe('100%'); + expect(element.style.position).toBe('absolute'); + expect(element.classList.contains('rounded-vera-large')).toBe(true); + expect(element.style.objectFit).toBe('contain'); + }); + + it('renders hidden message when entire screen is shared', () => { + const box = { height: 100, width: 100, top: 0, left: 0 }; + + render( + + ); + + expect(screen.getByText('You are sharing your screen.')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.tsx new file mode 100644 index 00000000..b85ede97 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/ScreenSharePublisher.tsx @@ -0,0 +1,67 @@ +import { ReactElement, useEffect, useRef } from 'react'; +import { Box } from 'opentok-layout-js'; +import { Publisher } from '@vonage/client-sdk-video'; +import VideoTile from '../VideoTile'; +import ScreenShareNameDisplay from '../../ScreenShareNameDisplay'; +import { useTranslation } from 'react-i18next'; + +export type ScreenSharePublisherProps = { + box: Box | undefined; + element: HTMLElement | HTMLObjectElement | undefined; + publisher: Publisher | null; + isEntireScreen: boolean; +}; + +/** + * ScreenSharePublisher Component + * Video Tile for local screen share publisher + * @param {ScreenSharePublisherProps} props - the props for this component + * @property {Box} box - Box specifying position and size of Video Tile + * @property {HTMLElement | HTMLObjectElement | undefined} element - VideoElement + * @property {Publisher | null} publisher-- Publisher object for local screen share + * @property {boolean} isEntireScreen - Whether the local user is sharing the entire screen + * @returns {ReactElement | undefined} - ScreenSharePublisher Component + */ +const ScreenSharePublisher = ({ + box, + element, + publisher, + isEntireScreen, +}: ScreenSharePublisherProps): ReactElement | undefined => { + const containerRef = useRef(null); + const { t } = useTranslation(); + useEffect(() => { + if (element && containerRef.current) { + element.classList.add('rounded-vera-large'); + Object.assign(element.style, { + width: '100%', + position: 'absolute', + objectFit: 'contain', + }); + containerRef.current.appendChild(element); + } + }, [element]); + const streamName = publisher?.stream?.name ?? ''; + return ( + box && ( + + {isEntireScreen ? ( +
        + {t('screenSharing.dialog.hiddenMessage')} +
        + ) : ( + + )} +
        + ) + ); +}; + +export default ScreenSharePublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/index.tsx new file mode 100644 index 00000000..24679407 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenSharePublisher/index.tsx @@ -0,0 +1,3 @@ +import ScreenSharePublisher from './ScreenSharePublisher'; + +export default ScreenSharePublisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.spec.tsx new file mode 100644 index 00000000..59833b3e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.spec.tsx @@ -0,0 +1,423 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Box } from 'opentok-layout-js'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import ScreenshareVideoTile from './ScreenshareVideoTile'; + +vi.mock('../ZoomIndicator', () => ({ + default: ({ + resetZoom, + zoomLevel, + zoomIn, + zoomOut, + }: { + resetZoom: () => void; + zoomLevel: number; + zoomIn: () => void; + zoomOut: () => void; + }) => ( +
        + {zoomLevel} + + + +
        + ), +})); + +vi.mock('../../../utils/helpers/getBoxStyle', () => ({ + default: (box: Box | undefined) => ({ + width: box?.width || 100, + height: box?.height || 100, + top: box?.top || 0, + left: box?.left || 0, + }), +})); + +vi.mock('../../../utils/constants', () => ({ + MAX_ZOOM: 5, + MIN_ZOOM: 0.5, + ZOOM_STEP: 0.25, +})); + +vi.mock('@vonage/client-sdk-video', () => ({ + hasMediaProcessorSupport: vi.fn(() => true), +})); + +describe('ScreenshareVideoTile', () => { + const defaultProps = { + 'data-testid': 'screenshare-tile', + box: { width: 800, height: 600, top: 0, left: 0 } as Box, + children:
        Video Content
        , + id: 'screenshare-1', + }; + + it('renders with basic props', () => { + render(); + + expect(screen.getByTestId('screenshare-tile')).toBeInTheDocument(); + expect(screen.getByTestId('video-content')).toBeInTheDocument(); + expect(screen.getByTestId('zoom-indicator')).toBeInTheDocument(); + }); + + it('renders with custom className', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + expect(tile).toHaveClass('ml-2'); + }); + + it('calls onMouseEnter and onMouseLeave handlers', () => { + const onMouseEnter = vi.fn(); + const onMouseLeave = vi.fn(); + + render( + + ); + + const tile = screen.getByTestId('screenshare-tile'); + + fireEvent.mouseEnter(tile); + expect(onMouseEnter).toHaveBeenCalledTimes(1); + + fireEvent.mouseLeave(tile); + expect(onMouseLeave).toHaveBeenCalledTimes(1); + }); + + describe('Zoom functionality', () => { + it('increases zoom level on wheel up', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + + expect(zoomLevel).toHaveTextContent('1'); + + fireEvent.wheel(tile, { deltaY: -100 }); + expect(zoomLevel).toHaveTextContent('1.25'); + }); + + it('decreases zoom level on wheel down', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + + // First zoom in + fireEvent.wheel(tile, { deltaY: -100 }); + expect(zoomLevel).toHaveTextContent('1.25'); + + // Then zoom out + fireEvent.wheel(tile, { deltaY: 100 }); + expect(zoomLevel).toHaveTextContent('1'); + }); + + it('respects minimum zoom level (0.5)', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + + fireEvent.wheel(tile, { deltaY: 100 }); + fireEvent.wheel(tile, { deltaY: 100 }); + fireEvent.wheel(tile, { deltaY: 100 }); + + expect(zoomLevel).toHaveTextContent('0.5'); + }); + + it('respects maximum zoom level (5)', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + + for (let i = 0; i < 20; i++) { + fireEvent.wheel(tile, { deltaY: -100 }); + } + + expect(zoomLevel).toHaveTextContent('5'); + }); + + it('resets zoom when reset button is clicked', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + const resetButton = screen.getByTestId('reset-zoom'); + + // Zoom in first + fireEvent.wheel(tile, { deltaY: -100 }); + expect(zoomLevel).toHaveTextContent('1.25'); + + // Reset zoom + fireEvent.click(resetButton); + expect(zoomLevel).toHaveTextContent('1'); + }); + + it('zooms in when zoom in button is clicked', () => { + render(); + + const zoomLevel = screen.getByTestId('zoom-level'); + const zoomInButton = screen.getByTestId('zoom-in'); + + expect(zoomLevel).toHaveTextContent('1'); + + fireEvent.click(zoomInButton); + expect(zoomLevel).toHaveTextContent('1.25'); + + fireEvent.click(zoomInButton); + expect(zoomLevel).toHaveTextContent('1.5'); + }); + + it('zooms out when zoom out button is clicked', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + const zoomOutButton = screen.getByTestId('zoom-out'); + + fireEvent.wheel(tile, { deltaY: -100 }); + fireEvent.wheel(tile, { deltaY: -100 }); + expect(zoomLevel).toHaveTextContent('1.5'); + + fireEvent.click(zoomOutButton); + expect(zoomLevel).toHaveTextContent('1.25'); + + fireEvent.click(zoomOutButton); + expect(zoomLevel).toHaveTextContent('1'); + }); + + it('handles zoomIn function correctly at boundaries', () => { + render(); + + const zoomLevel = screen.getByTestId('zoom-level'); + const zoomInButton = screen.getByTestId('zoom-in'); + + for (let i = 0; i < 20; i++) { + fireEvent.click(zoomInButton); + } + + expect(zoomLevel).toHaveTextContent('5'); + }); + + it('handles zoomOut function correctly at boundaries', () => { + render(); + + const zoomLevel = screen.getByTestId('zoom-level'); + const zoomOutButton = screen.getByTestId('zoom-out'); + + for (let i = 0; i < 20; i++) { + fireEvent.click(zoomOutButton); + } + + expect(zoomLevel).toHaveTextContent('0.5'); + }); + + it('resets pan offset when zooming back to 1x', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + const zoomOutButton = screen.getByTestId('zoom-out'); + + fireEvent.wheel(tile, { deltaY: -100 }); + fireEvent.mouseDown(tile, { clientX: 100, clientY: 100 }); + fireEvent.mouseMove(tile, { clientX: 150, clientY: 150 }); + fireEvent.mouseUp(tile); + + expect(zoomLevel).toHaveTextContent('1.25'); + + fireEvent.click(zoomOutButton); + expect(zoomLevel).toHaveTextContent('1'); + + fireEvent.wheel(tile, { deltaY: -100 }); + expect(zoomLevel).toHaveTextContent('1.25'); + }); + }); + + describe('Pan functionality', () => { + it('enables dragging when zoomed in', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + + fireEvent.wheel(tile, { deltaY: -100 }); + + fireEvent.mouseDown(tile, { clientX: 100, clientY: 100 }); + fireEvent.mouseMove(tile, { clientX: 150, clientY: 150 }); + fireEvent.mouseUp(tile); + + expect(tile).toBeInTheDocument(); + }); + + it('does not enable dragging when zoom level is 1', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + + fireEvent.mouseDown(tile, { clientX: 100, clientY: 100 }); + fireEvent.mouseMove(tile, { clientX: 150, clientY: 150 }); + fireEvent.mouseUp(tile); + + expect(tile).toBeInTheDocument(); + }); + + it('stops dragging on mouse leave', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + + fireEvent.wheel(tile, { deltaY: -100 }); + fireEvent.mouseDown(tile, { clientX: 100, clientY: 100 }); + + fireEvent.mouseLeave(tile); + + expect(tile).toBeInTheDocument(); + }); + }); + + describe('Box styling', () => { + it('renders with undefined box', () => { + render(); + + expect(screen.getByTestId('screenshare-tile')).toBeInTheDocument(); + }); + + it('applies box dimensions as styles', () => { + const customBox = { width: 400, height: 300, top: 50, left: 25 } as Box; + + render(); + + const tile = screen.getByTestId('screenshare-tile'); + expect(tile).toBeInTheDocument(); + }); + }); + + describe('Component structure', () => { + it('has correct CSS styles', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + expect(tile).toHaveStyle({ position: 'absolute' }); + expect(tile).toHaveStyle({ display: 'flex' }); + }); + + it('has correct id attribute', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + expect(tile).toHaveAttribute('id', 'screenshare-1'); + }); + + it('renders children content', () => { + const customChildren = ( +
        + Custom Video Element +
        + ); + + render({customChildren}); + + expect(screen.getByTestId('custom-content')).toBeInTheDocument(); + expect(screen.getByText('Custom Video Element')).toBeInTheDocument(); + }); + + it('renders ZoomIndicator with correct props', () => { + render(); + + expect(screen.getByTestId('zoom-indicator')).toBeInTheDocument(); + expect(screen.getByTestId('zoom-level')).toHaveTextContent('1'); + expect(screen.getByTestId('reset-zoom')).toBeInTheDocument(); + expect(screen.getByTestId('zoom-in')).toBeInTheDocument(); + expect(screen.getByTestId('zoom-out')).toBeInTheDocument(); + }); + }); + + describe('forwardRef functionality', () => { + it('forwards ref correctly', () => { + const ref = vi.fn(); + + render(); + + expect(ref).toHaveBeenCalled(); + }); + }); + + describe('Media processor support', () => { + it('renders ZoomIndicator when hasMediaProcessorSupport returns true', () => { + render(); + + expect(screen.getByTestId('zoom-indicator')).toBeInTheDocument(); + }); + + describe('when hasMediaProcessorSupport returns false', () => { + beforeEach(() => { + vi.mocked(hasMediaProcessorSupport).mockReturnValue(false); + }); + + afterEach(() => { + vi.mocked(hasMediaProcessorSupport).mockReturnValue(true); + }); + + it('does not render ZoomIndicator', () => { + render(); + + expect(screen.queryByTestId('zoom-indicator')).not.toBeInTheDocument(); + }); + + it('does not change zoom level on wheel events', () => { + const { rerender } = render(); + const tile = screen.getByTestId('screenshare-tile'); + + fireEvent.wheel(tile, { deltaY: -100 }); + fireEvent.wheel(tile, { deltaY: -100 }); + + // Re-enable support to expose ZoomIndicator and read the zoom level + vi.mocked(hasMediaProcessorSupport).mockReturnValue(true); + rerender(); + + expect(screen.getByTestId('zoom-level')).toHaveTextContent('1'); + }); + }); + }); + + describe('Zoom calculations', () => { + it('handles wheel events with precise zoom calculations', () => { + render(); + + const tile = screen.getByTestId('screenshare-tile'); + const zoomLevel = screen.getByTestId('zoom-level'); + + // Mock getBoundingClientRect for zoom calculation + Object.defineProperty(tile, 'getBoundingClientRect', { + writable: true, + value: vi.fn(() => ({ + width: 800, + height: 600, + left: 0, + top: 0, + })), + }); + + expect(zoomLevel).toHaveTextContent('1'); + + fireEvent.wheel(tile, { + deltaY: -100, + clientX: 400, // center X + clientY: 300, // center Y + }); + + expect(zoomLevel).toHaveTextContent('1.25'); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.tsx new file mode 100644 index 00000000..9ecd988a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/ScreenshareVideoTile.tsx @@ -0,0 +1,232 @@ +import { Box } from 'opentok-layout-js'; +import { + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, + WheelEvent, + useState, + MouseEvent, + useEffect, +} from 'react'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import getBoxStyle from '../../../utils/helpers/getBoxStyle'; +import ZoomIndicator from '../ZoomIndicator'; +import { MAX_ZOOM, MIN_ZOOM, ZOOM_STEP } from '../../../utils/constants'; +import CustomBox from '@mui/material/Box'; + +export type ScreenshareVideoTileProps = { + 'data-testid': string; + box: Box | undefined; + children: ReactNode; + className?: string; + id: string; + onMouseLeave?: () => void; + onMouseEnter?: () => void; +}; + +/** + * ScreenshareVideoTile Component + * + * A specialized video tile component for screenshare content with zoom and pan capabilities. + * @param {ScreenshareVideoTileProps} props - The props for the component + * @returns {ReactElement} - The ScreenshareVideoTile component. + */ +const ScreenshareVideoTile = forwardRef( + ( + { + 'data-testid': dataTestId, + box, + children, + className, + id, + onMouseEnter, + onMouseLeave, + }: ScreenshareVideoTileProps, + ref: ForwardedRef + ): ReactElement => { + const isZoomSupported = hasMediaProcessorSupport('both'); + // Zoom state management + const [zoomLevel, setZoomLevel] = useState(1); + const [panOffset, setPanOffset] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }); + const [isDragging, setIsDragging] = useState(false); + const [lastMousePosition, setLastMousePosition] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }); + + // Auto re-center when zoom returns to 100% + useEffect(() => { + if (zoomLevel === 1) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setPanOffset({ x: 0, y: 0 }); + setIsDragging(false); + } + }, [zoomLevel]); + + const onWheel = (event: WheelEvent) => { + if (!isZoomSupported) return; + + event.preventDefault(); + + const isWheelPositive = event.deltaY > 0; + const deltaZoom = isWheelPositive ? -ZOOM_STEP : ZOOM_STEP; + const newZoomLevel = Math.min(Math.max(zoomLevel + deltaZoom, MIN_ZOOM), MAX_ZOOM); + + // Get the container bounds and mouse position relative to container + const rect = event.currentTarget.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + // Calculate the center of the container + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Calculate the offset from center to mouse position + const offsetX = mouseX - centerX; + const offsetY = mouseY - centerY; + + // Calculate the new pan offset to keep the mouse position fixed during zoom + // We need to account for the zoom change and current pan offset + const zoomRatio = newZoomLevel / zoomLevel; + const newPanX = panOffset.x * zoomRatio + offsetX * (1 - zoomRatio); + const newPanY = panOffset.y * zoomRatio + offsetY * (1 - zoomRatio); + + setZoomLevel(newZoomLevel); + setPanOffset({ x: newPanX, y: newPanY }); + }; + + const handleMouseDown = (event: MouseEvent) => { + if (zoomLevel > 1) { + setIsDragging(true); + setLastMousePosition({ x: event.clientX, y: event.clientY }); + } + }; + + const handleMouseMove = (event: MouseEvent) => { + if (isDragging && zoomLevel > 1) { + const deltaX = event.clientX - lastMousePosition.x; + const deltaY = event.clientY - lastMousePosition.y; + + setPanOffset((prev) => ({ + x: prev.x + deltaX, + y: prev.y + deltaY, + })); + + setLastMousePosition({ x: event.clientX, y: event.clientY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + // Apply zoom transform style + const getTransformStyle = () => { + let cursor = 'default'; + if (zoomLevel > 1) { + cursor = isDragging ? 'grabbing' : 'grab'; + } + + return { + transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`, + transformOrigin: 'center', + transition: isDragging ? 'none' : 'transform 0.1s ease-out', + cursor, + }; + }; + + const resetZoom = () => { + setZoomLevel(1); + setPanOffset({ x: 0, y: 0 }); + setIsDragging(false); + }; + + const zoomIn = () => { + const newZoomLevel = Math.min(zoomLevel + ZOOM_STEP, MAX_ZOOM); + setZoomLevel(newZoomLevel); + // Reset pan offset when zooming in from 1x to maintain center position + if (zoomLevel === 1) { + setPanOffset({ x: 0, y: 0 }); + } + }; + + const zoomOut = () => { + const newZoomLevel = Math.max(zoomLevel - ZOOM_STEP, MIN_ZOOM); + setZoomLevel(newZoomLevel); + // Reset pan offset when zooming back to 1x + if (newZoomLevel === 1) { + setPanOffset({ x: 0, y: 0 }); + } + }; + + return ( + onMouseEnter?.()} + onMouseLeave={() => { + setIsDragging(false); + onMouseLeave?.(); + }} + onWheel={onWheel} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + > + + + {children} + + {isZoomSupported && ( + + )} + + ); + } +); + +ScreenshareVideoTile.displayName = 'ScreenshareVideoTile'; + +export default ScreenshareVideoTile; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/index.tsx new file mode 100644 index 00000000..160d0640 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ScreenshareVideoTile/index.tsx @@ -0,0 +1,3 @@ +import ScreenshareVideoTile from './ScreenshareVideoTile'; + +export default ScreenshareVideoTile; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/SendEmojiButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/SendEmojiButton.tsx new file mode 100644 index 00000000..c448d9ec --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/SendEmojiButton.tsx @@ -0,0 +1,37 @@ +import { ReactElement } from 'react'; +import useSessionContext from '../../../hooks/useSessionContext'; +import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; +import Button from '@mui/material/Button'; + +export type SendEmojiButtonProps = { + emoji: string; +}; + +/** + * SendEmojiButton Component + * + * Displays a clickable button to send emojis to all users in the meeting. + * @param {SendEmojiButtonProps} props - The props for the component. + * @returns {ReactElement} The SendEmojiButton component. + */ +const SendEmojiButton = ({ emoji }: SendEmojiButtonProps): ReactElement => { + const { sendEmoji } = useSessionContext(); + const isSmallViewport = useIsSmallViewport(); + const xs = isSmallViewport ? 2 : 3; + const size = isSmallViewport ? 'small' : 'large'; + + return ( +
        + +
        + ); +}; + +export default SendEmojiButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/index.tsx new file mode 100644 index 00000000..d0340283 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SendEmojiButton/index.tsx @@ -0,0 +1,3 @@ +import SendEmojiButton from './SendEmojiButton'; + +export default SendEmojiButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.spec.tsx new file mode 100644 index 00000000..c490c7c2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.spec.tsx @@ -0,0 +1,233 @@ +import { vi, describe, it, Mock, expect, beforeEach, beforeAll } from 'vitest'; +import { + makeMediaDeviceInfos, + makeMediaStreamMock, + setupWindowNavigatorMock, +} from '@web-test/fixtures'; +import { render as renderBase, screen, fireEvent, waitFor } from '@testing-library/react'; +import jwt from 'jsonwebtoken'; +import usePublisherContext from '@hooks/usePublisherContext'; +import { PublisherContextType } from '@Context/PublisherProvider'; +import mediaDevices$ from '@core/stores/devices'; +import { makeTestProvider, providers } from '@test/providers'; +import SmallViewportHeader from './SmallViewportHeader'; + +const { mockIsMobile } = vi.hoisted(() => ({ + mockIsMobile: vi.fn().mockReturnValue(false), +})); + +vi.mock('@hooks/usePublisherContext'); +vi.mock('@web/platform', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isMobile: mockIsMobile, + }; +}); + +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; + +const devices = makeMediaDeviceInfos(); +const videoDevices = devices.filter((d) => d.kind === 'videoinput'); + +const mockSessionId = '1_MX4xMjM0NTY3OH4-VGh1IEZlYiAyNyAwODozMjozNCBQU1QgMjAyMH4wLjI0NDYxMjE'; +const mockedRoomName = 'test-room-name'; +const mockSessionKey: string = jwt.sign( + { sessionId: mockSessionId, roomName: mockedRoomName }, + 'test', + { + algorithm: 'HS256', + } +); + +describe('SmallViewportHeader component', () => { + let publisherContext: PublisherContextType; + + beforeAll(() => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, + }); + + // Mock the native devices API + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + dispatchEvent: vi.fn().mockReturnValue(true), + enumerateDevices: Promise.resolve(devices), + getUserMedia: Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [], + getAudioTracks: [], + }) + ), + }, + }); + }); + + beforeEach(() => { + publisherContext = { + publisherContext: { cycleVideo: vi.fn() } as unknown as PublisherContextType['publisher'], + isVideoEnabled: true, + } as unknown as PublisherContextType; + + mockUsePublisherContext.mockReturnValue(publisherContext); + + // Initialize the store with video devices + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: devices, + })); + }); + + it('renders the room name', () => { + render(); + + expect(screen.getByText(mockedRoomName)).toBeInTheDocument(); + }); + + it('shows the recording icon if it is currently in progress', () => { + render(, { + initialValue: { archiveId: '123-456' }, + }); + + expect(screen.getByTestId('recordingIndicator')).toBeInTheDocument(); + }); + + it('does not show the recording icon if it there is not one happening', () => { + render(); + + expect(screen.queryByTestId('recordingIndicator')).not.toBeInTheDocument(); + }); + + it('copies room share URL to clipboard', async () => { + render(); + + const copyButton = screen.getByTestId('vivid-icon-copy-line'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining(mockSessionKey) + ); + expect(screen.getByTestId('vivid-icon-check-sent-line')).toBeInTheDocument(); + }); + }); + + it('shows the camera switch button when video is enabled', () => { + mockUsePublisherContext.mockReturnValue({ + publisherContext: { cycleVideo: vi.fn() } as unknown as PublisherContextType['publisher'], + isVideoEnabled: true, + } as unknown as PublisherContextType); + + render(); + + expect(screen.getByTestId('vivid-icon-camera-switch-line')).toBeInTheDocument(); + }); + + it('does not show the camera switch button when video is disabled', () => { + mockUsePublisherContext.mockReturnValue({ + publisherContext: { cycleVideo: vi.fn() } as unknown as PublisherContextType['publisher'], + isVideoEnabled: false, + } as unknown as PublisherContextType); + + render(); + + expect(screen.queryByTestId('vivid-icon-camera-switch-line')).not.toBeInTheDocument(); + }); + + it('does not show the camera switch button when only one video input device is available', () => { + mockUsePublisherContext.mockReturnValue({ + publisherContext: { cycleVideo: vi.fn() } as unknown as PublisherContextType['publisher'], + isVideoEnabled: true, + } as unknown as PublisherContextType); + + // Set the store to have only one video device + const singleVideoDevice = videoDevices[0]; + + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: [...devices.filter((d) => d.kind !== 'videoinput'), singleVideoDevice], + })); + + render(); + + expect(screen.queryByTestId('vivid-icon-camera-switch-line')).not.toBeInTheDocument(); + + // Restore full device list for subsequent tests + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: devices, + })); + }); + + it('toggles to the opposite camera device when clicked', async () => { + const currentDeviceId = videoDevices[0].deviceId; + const targetDeviceId = videoDevices[1].deviceId; + + const mockTrack = { + getSettings: vi.fn(() => ({ deviceId: targetDeviceId })), + stop: vi.fn(), + } as unknown as MediaStreamTrack; + + const mockStream = makeMediaStreamMock({ + getVideoTracks: [mockTrack], + getTracks: [mockTrack], + }); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + dispatchEvent: vi.fn().mockReturnValue(true), + enumerateDevices: Promise.resolve(devices), + getUserMedia: Promise.resolve(mockStream), + }, + }); + + // Allow getFacingMode to run without throwing (requires mobile) + mockIsMobile.mockReturnValueOnce(true); + + const selectDeviceSpy = vi + .spyOn(mediaDevices$.actions, 'selectDevice') + .mockResolvedValue(undefined as never); + + mockUsePublisherContext.mockReturnValue({ + publisher: { + getVideoSource: vi.fn().mockReturnValue({ + track: { getSettings: () => ({ facingMode: 'user' }) } as unknown as MediaStreamTrack, + deviceId: currentDeviceId, + }), + } as unknown as PublisherContextType['publisher'], + isVideoEnabled: true, + } as unknown as PublisherContextType); + + render(); + + const cameraIcon = screen.getByTestId('vivid-icon-camera-switch-line'); + fireEvent.click(cameraIcon); + + await waitFor(() => { + expect(selectDeviceSpy).toHaveBeenCalledTimes(1); + expect(selectDeviceSpy).toHaveBeenCalledWith('videoinput', targetDeviceId); + }); + }); +}); + +function render( + component: React.ReactElement, + sessionContext?: { initialValue?: { sessionKey?: string; archiveId?: string | null } } +) { + const { wrapper } = makeTestProvider([providers.user, providers.session, providers.runtime], { + userContext: undefined, + sessionContext: { + initialValue: { + sessionKey: mockSessionKey, + ...sessionContext?.initialValue, + }, + }, + runtimeContext: undefined, + }); + + return renderBase(component, { wrapper }); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx new file mode 100644 index 00000000..b3e49280 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/SmallViewportHeader.tsx @@ -0,0 +1,97 @@ +import { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import useSessionContext from '../../../hooks/useSessionContext'; +import useRoomShareUrl from '../../../hooks/useRoomShareUrl'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Fade from '@mui/material/Fade'; +import VividIcon from '@ui/VividIcon'; +import usePublisherContext from '@hooks/usePublisherContext'; +import RecordingIndicator from '../RecordingIndicator'; +import useDistinctLabelMediaDevices from '@ui/hooks/useDistinctLabelMediaDevices/useDistinctLabelMediaDevices'; +import { useSwitchCameraFacingModeHandler } from './hooks'; + +/** + * SmallViewportHeader Component + * + * This component shows a header bar in smaller viewport devices that consists of recording on/off indicator, + * meeting room name, and copy-to-clipboard button. + * @returns {ReactElement} The small viewport header component. + */ +const SmallViewportHeader = (): ReactElement => { + const { t } = useTranslation(); + const { archiveId, sessionDetails } = useSessionContext(); + const isRecording = !!archiveId; + + // Get preferred video input devices (cameras) + const videoInputDevices = useDistinctLabelMediaDevices('videoinput'); + + const roomShareUrl = useRoomShareUrl(); + const [isCopied, setIsCopied] = useState(false); + + const copyUrl = () => { + void navigator.clipboard.writeText(roomShareUrl); + + setIsCopied(true); + + // reset the icon back after a brief timeout + setTimeout(() => { + setIsCopied(false); + }, 3000); + }; + + const { isVideoEnabled } = usePublisherContext(); + + const { switchCameraFacingModeHandler } = useSwitchCameraFacingModeHandler(); + + return ( + + + {isRecording && } + + {sessionDetails?.roomName} + + + + {isVideoEnabled && videoInputDevices.length > 1 && ( + + + + + + )} + + + + + {isCopied ? ( + + ) : ( + + )} + + + + + + + ); +}; + +export default SmallViewportHeader; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/index.ts new file mode 100644 index 00000000..cbdb84c4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/index.ts @@ -0,0 +1 @@ +export { default as useSwitchCameraFacingModeHandler } from './useSwitchCameraFacingModeHandler'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.spec.ts new file mode 100644 index 00000000..70e040cf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { mediaDevices$ } from '@core/stores'; +import { + makeMediaDeviceInfos, + makeMediaStreamMock, + setupWindowNavigatorMock, + frontCameraId, + rearCameraId, +} from '@web-test/fixtures'; +import useSwitchCameraFacingModeHandler from './useSwitchCameraFacingModeHandler'; + +const devices = makeMediaDeviceInfos(); + +const getUserMedia = vi.fn(() => + Promise.resolve( + makeMediaStreamMock({ + getVideoTracks: [ + { + getSettings: () => ({ deviceId: rearCameraId }), + } as unknown as MediaStreamTrack, + ], + getTracks: [{ stop: vi.fn() } as unknown as MediaStreamTrack], + }) + ) +); + +describe('useSwitchCameraFacingModeHandler', () => { + beforeEach(() => { + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(devices), + getUserMedia, + }, + }); + + mediaDevices$.reset(); + mediaDevices$.setState((state) => ({ + ...state, + videoinput: frontCameraId, // start with front camera selected + })); + }); + + it('switches camera to the target facing mode and updates selected videoinput', async () => { + expect.assertions(2); + + const { result } = renderHook(() => useSwitchCameraFacingModeHandler()); + + expect(mediaDevices$.getState().videoinput).toBe(frontCameraId); + + await act(async () => { + await result.current.switchCameraFacingModeHandler(); + }); + + expect(mediaDevices$.getState().videoinput).toBe(rearCameraId); + }); + + it('keeps current selection when target facing mode is unavailable', async () => { + expect.assertions(2); + + getUserMedia.mockRejectedValueOnce(new Error('OverconstrainedError')); + + const { result } = renderHook(() => useSwitchCameraFacingModeHandler()); + + expect(mediaDevices$.getState().videoinput).toBe(frontCameraId); + + await act(async () => { + await result.current.switchCameraFacingModeHandler(); + }); + + expect(mediaDevices$.getState().videoinput).toBe(frontCameraId); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.ts new file mode 100644 index 00000000..14607848 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/hooks/useSwitchCameraFacingModeHandler.ts @@ -0,0 +1,120 @@ +import { attempt, tryCatch } from '@common/execution'; +import { FacingMode } from '@common/types'; +import { makeApplicationErrorMapper } from '@core/errors'; +import { mediaDevices$ } from '@core/stores'; +import useSelectDeviceHandler from '@hooks/useSelectDeviceHandler'; +import { handleClientApplicationError } from '@ui/helpers'; +import { useStableCallback } from '@web/hooks'; +import { t } from 'i18next'; + +/** + * Safely switch camera facing mode by attempting to get a new media stream with the desired facing mode constraint + */ +const useSwitchCameraFacingModeHandler = () => { + const { handleSelectDevice } = useSelectDeviceHandler(); + + const switchCameraFacingModeHandler = useStableCallback(async () => { + const { videoinput: currentDeviceId } = mediaDevices$.getState(); + const { videoinput: videoinputDevices } = mediaDevices$.mediaDevicesMap$.getState(); + + const { inferredFacingMode: currentFacingMode } = videoinputDevices[currentDeviceId!] || {}; + + const targetFacingMode = + currentFacingMode === FacingMode.user ? FacingMode.environment : FacingMode.user; + + try { + let { result: mediaStream, error } = await tryCatch(() => + navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { + exact: targetFacingMode, + }, + }, + }) + ); + + let targetDeviceLabel: string = 'unknown'; + + if (!mediaStream) { + const videoInputDevices = Object.values(videoinputDevices); + + // try to get a stream by device id as fallback + const targetDevice = videoInputDevices.find( + ({ inferredFacingMode, deviceId }) => + inferredFacingMode === targetFacingMode && deviceId !== currentDeviceId + ); + + if (!targetDevice) { + throw makeApplicationErrorMapper( + t('devices.video.camera.noDeviceError', { + facingMode: targetFacingMode, + }) + )(null); + } + + targetDeviceLabel = targetDevice.label; + + ({ result: mediaStream, error } = await tryCatch(() => + navigator.mediaDevices.getUserMedia({ + video: { + deviceId: targetDevice.deviceId, + }, + }) + )); + } + + if (error || !mediaStream) { + const applicationError = makeApplicationErrorMapper( + t('devices.video.camera.accessError', { + facingMode: targetFacingMode, + }) + )(error); + + if (targetDeviceLabel) { + applicationError.add( + t('devices.video.camera.selectedDevice', { + deviceName: targetDeviceLabel, + }) + ); + } + + throw applicationError; + } + + const [track] = mediaStream.getVideoTracks(); + const deviceId = track?.getSettings().deviceId; + + attempt(() => mediaStream?.getTracks().forEach((track) => track.stop())); + + if (!deviceId) { + throw makeApplicationErrorMapper( + t('devices.video.camera.noTrackError', { + facingMode: targetFacingMode, + }) + )(null); + } + + await handleSelectDevice({ mediaDeviceKind: 'videoinput', deviceId }); + } catch (error) { + const facingModeLabel = + targetFacingMode === FacingMode.user + ? t('devices.video.camera.front') + : t('devices.video.camera.rear'); + + const fallbackMessage = t('devices.video.camera.switchError', { + facingMode: facingModeLabel, + }); + + handleClientApplicationError(fallbackMessage, error); + } + }); + + return { + /** + * Handler to switch camera facing mode. + */ + switchCameraFacingModeHandler, + }; +}; + +export default useSwitchCameraFacingModeHandler; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/index.tsx new file mode 100644 index 00000000..053afd7c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/SmallViewportHeader/index.tsx @@ -0,0 +1,3 @@ +import SmallViewportHeader from './SmallViewportHeader'; + +export default SmallViewportHeader; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/TimeRoomName.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/TimeRoomName.tsx new file mode 100644 index 00000000..697f87cc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/TimeRoomName.tsx @@ -0,0 +1,32 @@ +import { ReactElement } from 'react'; +import useDateTime from '../../../hooks/useDateTime'; +import useSessionContext from '../../../hooks/useSessionContext'; +import Box from '@mui/material/Box'; + +/** + * TimeRoomName Component + * + * This component shows the current time and room name. + * @returns {ReactElement} - The Time and Room Name component. + */ +const TimeRoomName = (): ReactElement => { + const { time } = useDateTime(); + const { sessionDetails } = useSessionContext(); + + return ( + + {time} | {sessionDetails?.roomName} + + ); +}; + +export default TimeRoomName; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/index.tsx new file mode 100644 index 00000000..62c6901f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/TimeRoomName/index.tsx @@ -0,0 +1,3 @@ +import TimeRoomName from './TimeRoomName'; + +export default TimeRoomName; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx new file mode 100644 index 00000000..851184fc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.spec.tsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi, beforeEach, Mock, afterAll } from 'vitest'; +import { render as renderBase, screen } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; +import { ReactElement } from 'react'; +import useSpeakingDetector from '@hooks/useSpeakingDetector'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; +import useToolbarButtons, { + UseToolbarButtons, + UseToolbarButtonsProps, +} from '@hooks/useToolbarButtons'; +import { RIGHT_PANEL_BUTTON_COUNT } from '@utils/constants'; +import { makeTestProvider, providers } from '@test/providers'; +import { env } from '../../../env'; +import Toolbar, { ToolbarProps, CaptionsState } from './Toolbar'; + +const mockedRoomName = { roomName: 'test-room-name' }; + +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), + useParams: () => mockedRoomName, +})); + +vi.mock('@hooks/useSpeakingDetector'); +vi.mock('@utils/isReportIssueEnabled'); +vi.mock('@hooks/useToolbarButtons'); + +const mockUseSpeakingDetector = useSpeakingDetector as Mock<[], boolean>; +const mockIsReportIssueEnabled = isReportIssueEnabled as Mock<[], boolean>; +const mockUseToolbarButtons = useToolbarButtons as Mock< + [UseToolbarButtonsProps], + UseToolbarButtons +>; + +describe('Toolbar', () => { + beforeEach(() => { + env.reset(); + (useLocation as Mock).mockReturnValue({ + state: mockedRoomName, + }); + mockUseSpeakingDetector.mockReturnValue(false); + mockIsReportIssueEnabled.mockReturnValue(false); + mockUseToolbarButtons.mockImplementation( + ({ numberOfToolbarButtons }: UseToolbarButtonsProps) => { + const renderedToolbarButtons: UseToolbarButtons = { + displayTimeRoomName: true, + centerButtonLimit: numberOfToolbarButtons - RIGHT_PANEL_BUTTON_COUNT, + rightButtonLimit: numberOfToolbarButtons, + }; + return renderedToolbarButtons; + } + ); + }); + + afterAll(() => { + env.reset(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + const defaultProps: ToolbarProps = { + toggleShareScreen: vi.fn(), + isSharingScreen: false, + rightPanelActiveTab: 'closed', + toggleParticipantList: vi.fn(), + toggleChat: vi.fn(), + toggleReportIssue: vi.fn(), + toggleBackgroundEffects: vi.fn(), + participantCount: 0, + captionsState: { + isUserCaptionsEnabled: false, + setIsUserCaptionsEnabled: vi.fn(), + setCaptionsErrorResponse: vi.fn(), + } as CaptionsState, + }; + + it('does not render the Report Issue button if it is configured to be disabled', () => { + render(); + expect(screen.queryByTestId('report-issue-button')).not.toBeInTheDocument(); + }); + + it('renders the Report Issue button if it is configured to be enabled', () => { + mockIsReportIssueEnabled.mockReturnValue(true); + render(); + expect(screen.getByTestId('report-issue-button')).toBeInTheDocument(); + }); + + it('on a small viewport, displays the ToolbarOverflowButton button', () => { + mockUseToolbarButtons.mockReturnValue({ + displayTimeRoomName: false, + centerButtonLimit: 0, + rightButtonLimit: 0, + }); + + render(); + + expect(screen.queryByTestId('hidden-toolbar-items')).toBeVisible(); + + expect(screen.queryByTestId('archiving-button')).not.toBeVisible(); + expect(screen.queryByTestId('screensharing-button')).not.toBeVisible(); + expect(screen.queryByTestId('archiving-button')).not.toBeVisible(); + expect(screen.queryByTestId('emoji-grid-button')).not.toBeVisible(); + }); + + it('on a normal viewport, displays all of the toolbar buttons', () => { + env.partialUpdate({ MEETING_ROOM_ALLOW_ADVANCED_SETTINGS: true }); + render( + + ); + expect(screen.queryByTestId('archiving-button')).toBeVisible(); + expect(screen.queryByTestId('advanced-settings-button')).toBeVisible(); + expect(screen.queryByTestId('screensharing-button')).toBeVisible(); + expect(screen.queryByTestId('emoji-grid-button')).toBeVisible(); + expect(screen.queryByTestId('captions-button')).toBeVisible(); + }); + + it('does not render advanced settings when the flag is disabled', () => { + env.partialUpdate({ MEETING_ROOM_ALLOW_ADVANCED_SETTINGS: false }); + render(); + + expect(screen.queryByTestId('advanced-settings-button')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([providers.runtime]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx new file mode 100644 index 00000000..fe934369 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/Toolbar.tsx @@ -0,0 +1,231 @@ +import { Dispatch, ReactElement, SetStateAction, useCallback, useRef, useState } from 'react'; +import useSessionContext from '@hooks/useSessionContext'; +import { RightPanelActiveTab } from '@hooks/useRightPanel'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; +import useToolbarButtons from '@hooks/useToolbarButtons'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import Box from '@mui/material/Box'; +import { env } from '../../../env'; +import ScreenSharingButton from '../../ScreenSharingButton'; +import TimeRoomNameMeetingRoom from '../TimeRoomName'; +import ExitButton from '../ExitButton'; +import LayoutButton from '../LayoutButton'; +import ParticipantListButton from '../ParticipantListButton'; +import ArchivingButton from '../ArchivingButton'; +import CaptionsButton from '../CaptionsButton'; +import ChatButton from '../ChatButton'; +import ReportIssueButton from '../ReportIssueButton'; +import AdvancedSettingsButton from '../AdvancedSettingsButton'; +import ToolbarOverflowButton from '../ToolbarOverflowButton'; +import EmojiGridButton from '../EmojiGridButton'; +import DeviceControlButton from '../DeviceControlButton'; + +export type CaptionsState = { + isUserCaptionsEnabled: boolean; + setIsUserCaptionsEnabled: Dispatch>; + setCaptionsErrorResponse: Dispatch>; +}; + +export type ToolbarProps = { + toggleShareScreen: () => void; + isSharingScreen: boolean; + rightPanelActiveTab: RightPanelActiveTab; + toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; + toggleChat: () => void; + toggleReportIssue: () => void; + participantCount: number; + captionsState: CaptionsState; +}; + +/** + * Toolbar Component + * + * This component returns the UI for the toolbar that is displayed on the bottom of the meeting room. + * It displays the following items: + * - The current time and meeting room name + * - The microphone state with the ability to toggle it on/off and open a drop-down with some audio settings + * - The video state with the ability to toggle it on/off and open a dropdown with some video effects + * - Screensharing button (only on desktop devices) + * - Button to toggle current layout (grid or active speaker) + * - Button to express yourself (emojis) + * - Button to toggle captions on and off + * - Button to open a pop-up to start meeting recording (archiving) + * - Button containing hidden toolbar items when the viewport is narrow + * - Button to exit a meeting (redirects to the goodbye page) + * @param {ToolbarProps} props - the props for the component + * @property {() => void} toggleScreenShare - the prop to toggle the screen share on and off + * @property {boolean} isSharingScreen - the prop to check if the user is currently sharing a screen + * @property {boolean} isParticipantListOpen - the prop to check if the participant list is open + * @property {() => void} openParticipantList - the prop to open the participant list + * @property {CaptionsState} captionsState - the state of the captions, including whether they are enabled and a function to set an error message + * @returns {ReactElement} - the toolbar component + */ +const Toolbar = ({ + isSharingScreen, + toggleShareScreen, + rightPanelActiveTab, + toggleParticipantList, + toggleBackgroundEffects, + toggleChat, + toggleReportIssue, + participantCount, + captionsState, +}: ToolbarProps): ReactElement => { + const { disconnect, subscriberWrappers } = useSessionContext(); + const { destroyBackgroundPublisher } = useBackgroundPublisherContext(); + const isViewingScreenShare = subscriberWrappers.some((subWrapper) => subWrapper.isScreenshare); + const isScreenSharePresent = isViewingScreenShare || isSharingScreen; + const isPinningPresent = subscriberWrappers.some((subWrapper) => subWrapper.isPinned); + const handleLeave = useCallback(() => { + if (!disconnect) { + return; + } + disconnect(); + destroyBackgroundPublisher(); + }, [destroyBackgroundPublisher, disconnect]); + const [openEmojiGridDesktop, setOpenEmojiGridDesktop] = useState(false); + + // An array of buttons available for the toolbar. As the toolbar resizes, buttons may be hidden and moved to the + // ToolbarOverflowMenu to ensure a responsive layout without compromising usability. + const toolbarButtons: Array = [ + , + , + , + , + , + env.MEETING_ROOM_ALLOW_ADVANCED_SETTINGS && ( + + ), + isReportIssueEnabled() && ( + + ), + , + , + ]; + // We track the toolbar and the accompanying containers so we know which toolbar buttons to display, and whether the TimeRoomName should be displayed + const toolbarRef = useRef(null); + const timeRoomNameRef = useRef(null); + const mediaControlsRef = useRef(null); + const rightPanelControlsRef = useRef(null); + const overflowAndExitRef = useRef(null); + + const { displayTimeRoomName, centerButtonLimit, rightButtonLimit } = useToolbarButtons({ + timeRoomNameRef, + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons: toolbarButtons.length, + }); + + const toolbarButtonsDisplayed = rightButtonLimit; + // We display the overflow button when we don't have enough space to display all the toolbar buttons + const shouldShowOverflowButton = toolbarButtonsDisplayed < toolbarButtons.length; + const displayCenterToolbarButtons = (toolbarButton: ReactElement | false, index: number) => + index < centerButtonLimit && toolbarButton; + // Displays the right panel buttons - any additional buttons to be displayed that aren't in the center of the toolbar. + const displayRightPanelButtons = (toolbarButton: ReactElement | false, index: number) => + index >= centerButtonLimit && index < rightButtonLimit && toolbarButton; + // Array of `false` or right panel button ReactElements to display. + const rightPanelButtons = toolbarButtons.map(displayRightPanelButtons); + // We display the right panel if we have at least one right panel button to display. + const displayRightPanel = rightPanelButtons.some((rightPanelButton) => !!rightPanelButton); + + return ( + + + {displayTimeRoomName && } + + + + + + + {toolbarButtons.map(displayCenterToolbarButtons)} + + {shouldShowOverflowButton && ( + + )} + + + + + {rightPanelButtons} + + + ); +}; + +export default Toolbar; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/index.tsx new file mode 100644 index 00000000..e0203f05 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/Toolbar/index.tsx @@ -0,0 +1,3 @@ +import Toolbar from './Toolbar'; + +export default Toolbar; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/ToolbarButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/ToolbarButton.tsx new file mode 100644 index 00000000..8a2fc1ff --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/ToolbarButton.tsx @@ -0,0 +1,51 @@ +import { ForwardedRef, forwardRef, ReactElement } from 'react'; +import classNames from 'classnames'; +import { IconButton, IconButtonProps } from '@mui/material'; +import { twMerge } from 'tailwind-merge'; + +export type ToolbarButtonProps = IconButtonProps & { + icon: ReactElement; + isOverflowButton?: boolean; +}; + +/** + * ToolbarButton Component + * A common component for toolbar buttons to share styling. + * @param {ToolbarButtonProps} props - props for the component + * @property {Function} onClick - on click handler + * @property {ReactElement} icon - MUI Icon for button + * @property {SxProps} sx - (optional) MUI style object + * @property {string} id - (optional) the data-testid used in unit tests + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @returns {ReactElement} + */ +const ToolbarButton = forwardRef(function ToolbarButton( + props: ToolbarButtonProps, + ref: ForwardedRef +) { + const { className, icon: Icon, sx = {}, isOverflowButton, ...rest } = props; + + return ( + + {Icon} + + ); +}); + +export default ToolbarButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/index.tsx new file mode 100644 index 00000000..02c8b171 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarButton/index.tsx @@ -0,0 +1,3 @@ +import ToolbarButton from './ToolbarButton'; + +export default ToolbarButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx new file mode 100644 index 00000000..3347e1ff --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.spec.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { act, render as renderBase, screen, cleanup } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import ToolbarOverflowButton from './ToolbarOverflowButton'; +import { + ToolbarOverflowMenuProps, + CaptionsState, +} from '../ToolbarOverflowMenu/ToolbarOverflowMenu'; + +vi.mock('@hooks/useRoomName'); +describe('ToolbarOverflowButton', () => { + const mockSetLayoutMode = vi.fn(); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + const defaultProps: ToolbarOverflowMenuProps = { + toggleShareScreen: vi.fn(), + isSharingScreen: false, + toolbarButtonsCount: 0, + isEmojiGridOpen: false, + setIsEmojiGridOpen: vi.fn(), + closeMenu: vi.fn(), + isOpen: false, + captionsState: { + isUserCaptionsEnabled: false, + setIsUserCaptionsEnabled: vi.fn(), + setCaptionsErrorResponse: vi.fn(), + } as CaptionsState, + }; + it('renders', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = []; + context.layoutMode = 'grid'; + context.setLayoutMode = mockSetLayoutMode; + context.unreadCount = 0; + } + }, + }, + }); + expect(screen.queryByTestId('hidden-toolbar-items')).toBeInTheDocument(); + }); + it('toggling shows and hides the toolbar buttons', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = []; + context.layoutMode = 'grid'; + context.setLayoutMode = mockSetLayoutMode; + context.unreadCount = 0; + } + }, + }, + }); + expect(screen.queryByTestId('layout-button')).not.toBeVisible(); + expect(screen.queryByTestId('emoji-grid-button')).not.toBeVisible(); + expect(screen.queryByTestId('archiving-button')).not.toBeVisible(); + act(() => { + screen.getByTestId('hidden-toolbar-items').click(); + }); + expect(screen.queryByTestId('layout-button')).toBeVisible(); + expect(screen.queryByTestId('emoji-grid-button')).toBeVisible(); + expect(screen.queryByTestId('archiving-button')).toBeVisible(); + }); + it('should have the unread messages badge present', () => { + render(, { + sessionContext: { + __interceptor: (context) => { + if (context) { + context.subscriberWrappers = []; + context.layoutMode = 'grid'; + context.setLayoutMode = mockSetLayoutMode; + context.unreadCount = 0; + } + }, + }, + }); + // We expect the ChatButton in the ToolbarOverflowMenu and the ToolbarOverflowButton to have an unread messages badge present + expect(screen.queryAllByTestId('chat-button-unread-count').length).toBe(2); + }); +}); +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render(ui: ReactElement, { userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + userContext, + sessionContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.tsx new file mode 100644 index 00000000..2fa48785 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/ToolbarOverflowButton.tsx @@ -0,0 +1,99 @@ +import { Dispatch, ReactElement, useState, SetStateAction } from 'react'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import { useTranslation } from 'react-i18next'; +import ToolbarButton from '../ToolbarButton'; +import ToolbarOverflowMenu from '../ToolbarOverflowMenu'; +import UnreadMessagesBadge from '../UnreadMessagesBadge'; + +export type CaptionsState = { + isUserCaptionsEnabled: boolean; + setIsUserCaptionsEnabled: Dispatch>; + setCaptionsErrorResponse: Dispatch>; +}; + +export type ToolbarOverflowButtonProps = { + toggleShareScreen: () => void; + isSharingScreen: boolean; + toolbarButtonsCount: number; + captionsState: CaptionsState; +}; + +/** + * ToolbarOverflowButton Component + * + * Displays a clickable button that opens a grid of hidden toolbar buttons for smaller viewports. There + * is also an unread chat messages indicator that is shown when there are messages to be read. + * @param {ToolbarOverflowButtonProps} props - the props for the component + * @property {Function} toggleShareScreen - toggles the user's screenshare + * @property {boolean} isSharingScreen - whether the user is sharing their screen + * @property {number} toolbarButtonsCount - number of buttons displayed on the toolbar + * @property {CaptionsState} captionsState - the state of the captions, including whether they are enabled and functions to set error messages + * @returns {ReactElement} - The ToolbarOverflowButton Component. + */ +const ToolbarOverflowButton = ({ + toggleShareScreen, + isSharingScreen, + toolbarButtonsCount, + captionsState, +}: ToolbarOverflowButtonProps): ReactElement => { + const { t } = useTranslation(); + const [isToolbarOverflowMenuOpen, setIsToolbarOverflowMenuOpen] = useState(false); + const [openEmojiGridMobile, setOpenEmojiGridMobile] = useState(true); + + const handleButtonToggle = () => { + setIsToolbarOverflowMenuOpen((prevOpen) => !prevOpen); + }; + + const handleClickAway = (event?: MouseEvent | TouchEvent) => { + if (event) { + const target = event.target as HTMLElement; + if (target.closest('#hidden-toolbar-items')) { + return; + } + } + setIsToolbarOverflowMenuOpen(false); + }; + + return ( + <> + + + + } + sx={{ + marginRight: '0px', + width: '48px', + }} + /> + + + + + ); +}; + +export default ToolbarOverflowButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/index.tsx new file mode 100644 index 00000000..f323624f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowButton/index.tsx @@ -0,0 +1,3 @@ +import ToolbarOverflowButton from './ToolbarOverflowButton'; + +export default ToolbarOverflowButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx new file mode 100644 index 00000000..9395b869 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/MeetingRoom/ToolbarOverflowMenu/ToolbarOverflowMenu.spec.tsx @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render as renderBase, screen } from '@testing-library/react'; +import { ReactElement, useRef } from 'react'; +import userEvent from '@testing-library/user-event'; +import { isMobile } from '@web/platform'; +import isReportIssueEnabled from '@utils/isReportIssueEnabled'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import { env } from '../../../env'; +import ToolbarOverflowMenu, { CaptionsState } from './ToolbarOverflowMenu'; +import Button from '@mui/material/Button'; + +vi.mock('@hooks/useRoomName'); +vi.mock('@web/platform'); +vi.mock('@utils/isReportIssueEnabled'); + +const mockOpenEmojiGrid = vi.fn(); +const mockHandleClickAway = vi.fn(); +const mockIsReportIssueEnabled = isReportIssueEnabled as Mock<[], boolean>; + +const mockCaptionsState = { + isUserCaptionsEnabled: false, + setIsUserCaptionsEnabled: vi.fn(), + setCaptionsErrorResponse: vi.fn(), +} as CaptionsState; + +const TestComponent = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { + const anchorRef = useRef(null); + + return ( + <> + + ); +}; + +export default NewRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/NewRoomButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/NewRoomButton/index.tsx new file mode 100644 index 00000000..bce9415d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/NewRoomButton/index.tsx @@ -0,0 +1,3 @@ +import NewRoomButton from './NewRoomButton'; + +export default NewRoomButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/Publisher.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/Publisher.tsx new file mode 100644 index 00000000..a5c3f0ab --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/Publisher.tsx @@ -0,0 +1,103 @@ +import { ReactElement, useEffect, useRef } from 'react'; +import { Box } from 'opentok-layout-js'; +import usePublisherContext from '../../hooks/usePublisherContext'; +import VoiceIndicatorIcon from '../MeetingRoom/VoiceIndicator'; +import useAudioLevels from '../../hooks/useAudioLevels'; +import AvatarInitials from '../AvatarInitials'; +import NameDisplay from '../MeetingRoom/NameDisplay'; +import AudioIndicator from '../MeetingRoom/AudioIndicator'; +import VideoTile from '../MeetingRoom/VideoTile'; +import { ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE } from '@utils/constants'; +import toRemValue from '@common/helpers/toRemValue'; + +export type PublisherProps = { + box: Box; +}; + +/** + * Publisher component + * + * This component renders a VideoTile with Publisher video and an overlay. + * It consists of a video stream, initials, a publisher speaking indicator, and the user's name. + * @param {PublisherProps} props - the props for the component + * @property {Box} box - the box in which the component is displayed + * @returns {ReactElement} The publisher component. + */ +const Publisher = ({ box }: PublisherProps): ReactElement => { + const { + publisherVideoElement: element, + isVideoEnabled, + publisher, + isAudioEnabled, + } = usePublisherContext(); + const audioLevel = useAudioLevels(); + // We store this in a ref to get a reference to the div so that we can append a video to it + const pubContainerRef = useRef(null); + useEffect(() => { + if (element && pubContainerRef.current) { + element.classList.add('video__element', 'rounded-vera-large'); + + // eslint-disable-next-line react-hooks/immutability + element.style.width = '100%'; + element.style.height = '100%'; + element.style.position = 'absolute'; + element.style.objectFit = 'contain'; + element.style.transformOrigin = '50% 50%'; // origin-[50%_50%] + element.style.transform = 'scaleX(-1)'; // -scale-x-100 (mirror the publisher) + + pubContainerRef.current.appendChild(element); + } + }, [element]); + + const initials = publisher?.stream?.initials; + const username = publisher?.stream?.name ?? ''; + const hasVideo = isVideoEnabled && !!element; + const audioIndicatorStyle: React.CSSProperties = { + position: 'absolute', + top: toRemValue(ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE), + right: toRemValue(ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE), + height: '1.5rem', + width: '1.5rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: 'auto', + }; + + return ( + + {!hasVideo && ( + + )} + {isAudioEnabled ? ( + + ) : ( + + )} + + + ); +}; + +export default Publisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/index.tsx new file mode 100644 index 00000000..62f94ed5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Publisher/index.tsx @@ -0,0 +1,3 @@ +import Publisher from './Publisher'; + +export default Publisher; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx new file mode 100644 index 00000000..570280a6 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.spec.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, Mock } from 'vitest'; +import { checkSystemRequirements } from '@vonage/client-sdk-video'; +import { render } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import RedirectToUnsupportedBrowserPage from './RedirectToUnsupportedBrowserPage'; + +vi.mock('@vonage/client-sdk-video', () => ({ checkSystemRequirements: vi.fn() })); + +describe('RedirectToUnsupportedBrowserPage', () => { + const supportedText = 'You have arrived'; + const unsupportedText = 'Your browser is unsupported'; + const TestComponent = () =>
        {supportedText}
        ; + + it('for unsupported browsers, redirects to the unsupported browser page', () => { + // Mocking an unsupported browser + (checkSystemRequirements as Mock).mockReturnValue(0); + + const { getByText } = render( + + + {unsupportedText}
        } /> + + }> + } /> + + + + ); + + expect(getByText(unsupportedText)).toBeInTheDocument(); + }); + + it('for supported browsers, navigates to your destination', () => { + // Mocking a supported browser + (checkSystemRequirements as Mock).mockReturnValue(1); + + const { getByText } = render( + + + {unsupportedText}
        } /> + }> + } /> + + + + ); + + expect(getByText(supportedText)).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx new file mode 100644 index 00000000..0ef79d30 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/RedirectToUnsupportedBrowserPage.tsx @@ -0,0 +1,17 @@ +import { ReactElement } from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { checkSystemRequirements } from '@vonage/client-sdk-video'; + +/** + * This component checks whether the user's browser is supported by the Vonage Video API. + * If the browser is unsupported, users are redirected to the unsupported browser page. + * @param {RedirectToUnsupportedBrowserPageProps} props - The props for this component. + * @returns {ReactElement} - The redirect to unsupported browser page component. + */ +const RedirectToUnsupportedBrowserPage = (): ReactElement => { + const isSupportedBrowser = checkSystemRequirements() === 1; + + return isSupportedBrowser ? : ; +}; + +export default RedirectToUnsupportedBrowserPage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/index.tsx new file mode 100644 index 00000000..c6ae1aef --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToUnsupportedBrowserPage/index.tsx @@ -0,0 +1,3 @@ +import RedirectToUnsupportedBrowserPage from './RedirectToUnsupportedBrowserPage'; + +export default RedirectToUnsupportedBrowserPage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx new file mode 100644 index 00000000..4ba62789 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.spec.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { render } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import { ReactElement } from 'react'; +import { env } from '../../env'; +import RedirectToWaitingRoom from './RedirectToWaitingRoom'; + +const mockedParams = { roomIdentifier: 'test-room-name' }; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useParams: () => mockedParams, + }; +}); + +describe('RedirectToWaitingRoom Component', () => { + const TestComponent = (): ReactElement =>
        TestComponent
        ; + + it('navigates to the waiting room if the user does not have access', () => { + env.BYPASS_WAITING_ROOM = false; + + const { container } = render( + + + In waiting room
        } /> + + + + } + /> + + + ); + + expect(container.textContent).toBe('In waiting room'); + }); + + it('navigates to the waiting room if the user does not have access by setting bypass to false', () => { + env.BYPASS_WAITING_ROOM = false; + + const { container } = render( + + + In waiting room
        } /> + + + + } + /> + + + ); + + expect(container.textContent).toBe('In waiting room'); + }); + + it('renders the child component if the user set bypass to true', () => { + env.BYPASS_WAITING_ROOM = false; + + const { getByText } = render( + + + + + + ); + + expect(getByText('TestComponent')).toBeInTheDocument(); + }); + + it('renders the child component if the user set BYPASS_WAITING_ROOM to true', () => { + env.BYPASS_WAITING_ROOM = true; + + const { getByText } = render( + + + + + + ); + + expect(getByText('TestComponent')).toBeInTheDocument(); + }); + + it('renders the child component if the user has access', () => { + env.BYPASS_WAITING_ROOM = false; + + const { getByText } = render( + + + + + + ); + + expect(getByText('TestComponent')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.tsx new file mode 100644 index 00000000..8f6207e5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/RedirectToWaitingRoom.tsx @@ -0,0 +1,39 @@ +import { ReactElement } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import useSessionKeyParam from '../../hooks/useSessionKeyParam'; +import { env } from '../../env'; + +export type RedirectToWaitingRoomProps = { + children: ReactElement; +}; + +/** + * This component checks whether user should be redirected to the waiting room, or + * be taken directly to the meeting room. + * Users can bypass being redirected to the waiting room by adding ?bypass=true + * to the URL. Developers can bypass the waiting room by adding BYPASS_WAITING_ROOM=true in the frontend/.env file + * @param {RedirectToWaitingRoomProps} props - the props for this component. + * @property {ReactElement} children - the react elements to render if the user has access to the meeting room. + * @returns {ReactElement} - the redirect to waiting room component. + */ +const RedirectToWaitingRoom = ({ children }: RedirectToWaitingRoomProps): ReactElement => { + const location = useLocation(); + const { sessionKey } = useSessionKeyParam(); + const hasAccess = !!location.state?.hasAccess; + + const searchParams = new URLSearchParams(location.search); + const bypass = searchParams.get('bypass') === 'true' || env.BYPASS_WAITING_ROOM; + const mustEnterWaitingRoom = !hasAccess && !bypass; + + return mustEnterWaitingRoom ? ( + + ) : ( + children + ); +}; + +export default RedirectToWaitingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/index.tsx new file mode 100644 index 00000000..97e6a7bc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RedirectToWaitingRoom/index.tsx @@ -0,0 +1,3 @@ +import RedirectToWaitingRoom from './RedirectToWaitingRoom'; + +export default RedirectToWaitingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.spec.tsx new file mode 100644 index 00000000..d5c2810d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.spec.tsx @@ -0,0 +1,73 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { useNavigate } from 'react-router-dom'; +import MemoryRouter from '@test/RouterWrapper'; +import { makeTestProvider, providers } from '@test/providers'; +import type { VideoClient } from '@core/services'; +import RoomJoinContainer from './index'; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useNavigate: vi.fn(), + }; +}); +const mockNavigate = vi.fn(); + +vi.mock('../../utils/generateRoomName', () => ({ + __esModule: true, + default: () => 'mocked-room', +})); + +const mockCreateSessionMutate = vi.fn(); +const mockVideoClient = { + createSession: (...args: unknown[]) => mockCreateSessionMutate(...args) as unknown, +} as unknown as VideoClient; + +vi.mock('../JoinContainerSeparator', () => ({ + __esModule: true, + default: () =>
        , +})); +vi.mock('../JoinExistingRoom', () => ({ + __esModule: true, + default: () =>
        , +})); + +describe('RoomJoinContainer', () => { + beforeEach(() => { + (useNavigate as Mock).mockReturnValue(mockNavigate); + mockCreateSessionMutate.mockResolvedValue({ sessionKey: 'mock-session-key' }); + }); + + it('renders NewRoomButton, JoinContainerSeparator and JoinExistingRoom components', () => { + render( + + + + ); + expect(screen.getByTestId('new-room-button')).toBeInTheDocument(); + expect(screen.getByTestId('separator')).toBeInTheDocument(); + expect(screen.getByTestId('join-existing-room')).toBeInTheDocument(); + }); + + it('navigates to the waiting room when NewRoomButton is clicked', async () => { + render( + + + + ); + + screen.getByTestId('new-room-button').click(); + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/waiting-room/mock-session-key'); + }); + }); +}); + +function render(ui: React.ReactElement) { + const { wrapper } = makeTestProvider([providers.runtime], { + runtimeContext: { videoClient: mockVideoClient }, + }); + return renderBase(ui, { wrapper }); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.tsx new file mode 100644 index 00000000..f88c965e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/RoomJoinContainer.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from 'react-router-dom'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { runtime$ } from '@core/stores'; +import Card from '@ui/Card'; +import generateRoomName from '../../utils/generateRoomName'; +import NewRoomButton from '../NewRoomButton'; +import JoinContainerSeparator from '../JoinContainerSeparator'; +import JoinExistingRoom from '../JoinExistingRoom'; + +/** + * RoomJoinContainer Component + * + * This component renders UI elements for creating a new room or joining an existing one. + * @returns {ReactElement} The room join container component. + */ +const RoomJoinContainer = (): ReactElement => { + const videoClient = runtime$.useVideoClient(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleNewRoom = async () => { + const randomRoom = generateRoomName(); + + const { sessionKey } = await videoClient.createSession({ roomName: randomRoom }); + + navigate(`/waiting-room/${sessionKey}`); + }; + + return ( + +
        + {t('button.startNewRoom')} +
        + + + + +
        + {t('button.joinExistingMeeting')} +
        + + +
        + ); +}; + +export default RoomJoinContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/index.tsx new file mode 100644 index 00000000..6b4d94ea --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomJoinContainer/index.tsx @@ -0,0 +1,3 @@ +import RoomJoinContainer from './RoomJoinContainer'; + +export default RoomJoinContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.spec.tsx new file mode 100644 index 00000000..d8cc4eb3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.spec.tsx @@ -0,0 +1,126 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { SetStateAction, Dispatch } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import RoomNameInput from './RoomNameInput'; + +describe('RoomNameInputComponent', () => { + let setRoomName: Dispatch>; + let setHasError: Dispatch>; + + beforeEach(() => { + setRoomName = vi.fn(); + setHasError = vi.fn(); + }); + + describe('should set the room name', () => { + const testCases = [ + { input: 'test_room' }, + { input: 'test+room' }, + { input: 'another-test_room' }, + { input: '123testroom' }, + ]; + + testCases.forEach(({ input }) => { + it('if the input is valid', () => { + render( + + ); + + const inputBox = screen.getByRole('textbox'); + fireEvent.change(inputBox, { target: { value: input } }); + + expect(setHasError).toHaveBeenCalledWith(false); + expect(setRoomName).toHaveBeenCalledWith(input); + }); + }); + }); + + describe('should reject the name', () => { + const testCases = [ + { input: 'test@room' }, + { input: 'invalid#name' }, + { input: 'invalid/room/name' }, + { input: 'invalid%room%name' }, + ]; + + testCases.forEach(({ input }) => { + it('if the input is invalid', () => { + render( + + ); + + const inputBox = screen.getByRole('textbox'); + fireEvent.change(inputBox, { target: { value: input } }); + + expect(setHasError).toHaveBeenCalledWith(true); + expect(setRoomName).not.toHaveBeenCalledWith(input); + }); + }); + }); + + it('should set the room name as lowercase even if the parameter is a mix of both lower and upper case', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + const newRoomName = 'thisIsANewRoom'; + fireEvent.change(input, { target: { value: newRoomName } }); + + expect(setHasError).toHaveBeenCalledWith(false); + expect(setRoomName).toHaveBeenCalledWith(newRoomName.toLowerCase()); + }); + + it('should show an error if the input contains invalid characters', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: '%20' } }); + + expect(setHasError).toHaveBeenCalledWith(true); + expect(setRoomName).not.toHaveBeenCalled(); + expect( + screen.getByText('Cannot be empty, contain spaces or special characters') + ).toBeInTheDocument(); + }); + + it('should not show an error if the user erased the input', () => { + render( + + ); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: '' } }); + + expect(setHasError).toHaveBeenCalledWith(false); + // The room name gets set to its original state + expect(setRoomName).toHaveBeenCalledWith(''); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.tsx new file mode 100644 index 00000000..6fe6c20d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/RoomNameInput.tsx @@ -0,0 +1,66 @@ +import { SetStateAction, Dispatch, ReactElement, ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import { isValidRoomName } from '@common/assertions'; + +export type RoomNameInputProps = { + setRoomName: Dispatch>; + roomName: string; + hasError: boolean; + setHasError: Dispatch>; +}; + +/** + * RoomNameInput Component + * + * Input box for setting a custom room name. + * @param {RoomNameInputProps} props - The props for the component. + * @property {Dispatch>} setRoomName - Function to update the room name. + * @property {string} roomName - The room name set by the user. + * @property {boolean} hasError - The error state indicating whether user added an input with an error. + * @property {Dispatch>} setHasError - Function to update the error state. + * @returns {ReactElement} - The RoomNameInput component. + */ +const RoomNameInput = ({ + setRoomName, + roomName, + hasError, + setHasError, +}: RoomNameInputProps): ReactElement => { + const { t } = useTranslation(); + const handleChange = (textChangeEvent: ChangeEvent) => { + const newValue = textChangeEvent.target.value.toLowerCase(); + + if (newValue === '') { + // If the input is empty, reset the room name and clear the error + setRoomName(''); + setHasError(false); + return; + } + + if (isValidRoomName(newValue)) { + setHasError(false); + setRoomName(newValue); + } else { + setHasError(true); + } + }; + + return ( + + ); +}; + +export default RoomNameInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/index.tsx new file mode 100644 index 00000000..337a4ad0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/RoomNameInput/index.tsx @@ -0,0 +1,3 @@ +import RoomNameInput from './RoomNameInput'; + +export default RoomNameInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/ScreenShareNameDisplay.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/ScreenShareNameDisplay.tsx new file mode 100644 index 00000000..10544c78 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/ScreenShareNameDisplay.tsx @@ -0,0 +1,32 @@ +import type { Box } from 'opentok-layout-js'; +import type { ReactElement } from 'react'; + +export type ScreenShareNameDisplayProps = { + name: string; + box: Box; +}; + +/** + * ScreenShareNameDisplay Component + * + * Displays the name of who's screen-sharing along with an identifier that it's a screen video tile. + * @param {ScreenShareNameDisplayProps} props - The props for the component. + * @returns {ReactElement} The ScreenShareNameDisplay component. + */ +const ScreenShareNameDisplay = ({ name, box }: ScreenShareNameDisplayProps): ReactElement => { + const safeMaxWidth = typeof box.width === 'number' && Number.isFinite(box.width) ? box.width : 0; + + return ( +
        + {name} +
        + ); +}; + +export default ScreenShareNameDisplay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/index.tsx new file mode 100644 index 00000000..a7538cd2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenShareNameDisplay/index.tsx @@ -0,0 +1,3 @@ +import ScreenShareNameDisplay from './ScreenShareNameDisplay'; + +export default ScreenShareNameDisplay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx new file mode 100644 index 00000000..d5d533da --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.spec.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render as renderBase, screen } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { makeTestProvider } from '@test/providers'; +import ScreenSharingButton, { ScreenShareButtonProps } from './ScreenSharingButton'; +import { env } from '../../env'; + +describe('ScreenSharingButton', () => { + const mockToggleScreenShare = vi.fn(); + + const defaultProps: ScreenShareButtonProps = { + toggleScreenShare: mockToggleScreenShare, + isSharingScreen: false, + isViewingScreenShare: false, + }; + + it('renders the share screen button', () => { + render(); + expect(screen.getByTestId('ScreenShareIcon')).toBeInTheDocument(); + + const button = screen.getByRole('button'); + button.click(); + expect(mockToggleScreenShare).toHaveBeenCalled(); + }); + + it('renders the share screen off button', () => { + render(); + expect(screen.getByTestId('ScreenShareIcon')).toBeInTheDocument(); + + const button = screen.getByRole('button'); + button.click(); + expect(mockToggleScreenShare).toHaveBeenCalled(); + }); + + it('renders the pop up dialog to confirm that user wants to kick off another screenshare', () => { + render(); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect( + screen.getByText( + 'Looks like there is someone else sharing their screen. If you continue, their screen is no longer going to be shared.' + ) + ).toBeInTheDocument(); + }); + + it('is not rendered when allowScreenShare is false', () => { + env.partialUpdate({ + ALLOW_SCREEN_SHARE: false, + }); + + render(); + + expect(screen.queryByTestId('ScreenShareIcon')).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx new file mode 100644 index 00000000..da16c94d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/ScreenSharingButton.tsx @@ -0,0 +1,108 @@ +import Tooltip from '@mui/material/Tooltip'; +import { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isMobile } from '@web/platform'; +import ToolbarButton from '../MeetingRoom/ToolbarButton'; +import PopupDialog, { DialogTexts } from '../MeetingRoom/PopupDialog'; +import VividIcon from '@ui/VividIcon'; +import { env } from '../../env'; + +export type ScreenShareButtonProps = { + toggleScreenShare: () => void; + isSharingScreen: boolean; + isViewingScreenShare: boolean; + isOverflowButton?: boolean; +}; + +/** + * ScreenSharingButton Component + * + * Button to toggle on a user's screenshare and to display whether a user is sharing their screen. + * @param {ScreenShareButtonProps} props - The props for the component. + * @property {Function} toggleScreenShare - Function to toggle screenshare. + * @property {boolean} isSharingScreen - Whether the user is sharing their screen or not. + * @property {boolean} isViewingScreenShare - Indicates whether there is a screenshare currently in the session. + * @property {boolean} isOverflowButton - (optional) whether the button is in the ToolbarOverflowMenu + * @returns {ReactElement} - The ScreenSharingButton component + */ +const ScreenSharingButton = ({ + toggleScreenShare, + isSharingScreen, + isViewingScreenShare, + isOverflowButton = false, +}: ScreenShareButtonProps): ReactElement | false => { + const { t } = useTranslation(); + const title = isSharingScreen ? t('screenSharing.title.stop') : t('screenSharing.title.start'); + const [isModalOpen, setIsModalOpen] = useState(false); + + // Screensharing relies on the getDisplayMedia browser API which is unsupported on mobile devices + // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#browser_compatibility + const shouldDisplayScreenShareButton = !isMobile() && env.ALLOW_SCREEN_SHARE; + + const handleButtonClick = () => + isViewingScreenShare ? setIsModalOpen((prev) => !prev) : toggleScreenShare(); + + const handleClose = () => { + setIsModalOpen(false); + }; + + const actionText: DialogTexts = { + title: t('screenSharing.dialog.title'), + contents: t('screenSharing.dialog.content'), + primaryActionText: t('screenSharing.dialog.action'), + secondaryActionText: t('button.cancel'), + }; + + const handleActionClick = () => { + toggleScreenShare(); + handleClose(); + }; + + return ( + shouldDisplayScreenShareButton && ( + <> + + + ) : ( + + ) + } + sx={{ + marginTop: isOverflowButton ? '0px' : '4px', + marginLeft: isOverflowButton ? '12px' : '0px', + backgroundColor: isSharingScreen + ? 'color-mix(in srgb, var(--vera-on-secondary-light) 33%, transparent) !important' + : undefined, + }} + isOverflowButton={isOverflowButton} + /> + + {isViewingScreenShare && ( + + )} + + ) + ); +}; + +export default ScreenSharingButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/index.tsx new file mode 100644 index 00000000..69dd10dc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/ScreenSharingButton/index.tsx @@ -0,0 +1,3 @@ +import ScreenSharingButton from './ScreenSharingButton'; + +export default ScreenSharingButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.spec.tsx new file mode 100644 index 00000000..1946aa9f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.spec.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import Separator from './Separator'; + +describe('Separator', () => { + it('renders the separator component and applies the correct class for default orientation', () => { + render(); + + const separator = screen.getByTestId('separator'); + expect(separator).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.tsx new file mode 100644 index 00000000..58a891f9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/Separator.tsx @@ -0,0 +1,32 @@ +import Box from '@mui/material/Box'; +import { ReactElement } from 'react'; + +export type SeparatorProps = { + orientation?: 'left' | 'right'; + width?: string; +}; + +/** + * Separator Component + * + * This component renders a horizontal line with customizable orientation that is set to left by default. + * @param {SeparatorProps} props - the props for the component. + * @property {'left' | 'right'} orientation - whether the separator is oriented to the left or right + * @property {string} width - the width of the separator. + * @returns {ReactElement} The separator component. + */ +const Separator = ({ orientation = 'left', width = '50%' }: SeparatorProps): ReactElement => { + return ( + + ); +}; + +export default Separator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/index.tsx new file mode 100644 index 00000000..c8396515 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Separator/index.tsx @@ -0,0 +1,3 @@ +import Separator from './Separator'; + +export default Separator; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.spec.tsx new file mode 100644 index 00000000..2b26d8bf --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.spec.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act, render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import VividIcon from '@ui/VividIcon'; +import SoundTest from './SoundTest'; +import { nativeDevices } from '../../utils/mockData/device'; +import { makeTestProvider } from '@test/providers'; +import { ReactElement } from 'react'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +describe('SoundTest', () => { + const playMock = vi.fn(); + const pauseMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + enumerateDevices: new Promise((res) => { + res(nativeDevices); + }), + }, + }); + + global.Audio = vi.fn().mockImplementation(() => ({ + play: playMock, + pause: pauseMock, + currentTime: 9001, + setSinkId: vi.fn(), + })); + }); + + it('renders', () => { + render( + + + + ); + + const renderedSoundTest = screen.getByTestId('soundTest'); + + expect(renderedSoundTest).toBeInTheDocument(); + }); + + it('clicking the SoundTest toggles audio playing', () => { + render( + + + + ); + + const renderedSoundTest = screen.getByTestId('soundTest'); + act(() => { + renderedSoundTest.click(); + }); + + // First toggle should play the audio + expect(playMock).toHaveBeenCalledOnce(); + expect(pauseMock).not.toHaveBeenCalled(); + + // Reset the mock and click again + playMock.mockReset(); + act(() => { + renderedSoundTest.click(); + }); + + // Second toggle should pause the audio + expect(playMock).not.toHaveBeenCalledOnce(); + expect(pauseMock).toHaveBeenCalledOnce(); + }); + + it('displays `Test speakers` when audio is not playing', () => { + render( + + + + ); + + const displayedText = screen.getByText('Test speakers'); + expect(displayedText).toBeInTheDocument(); + }); + + it('displays `Stop testing` when audio is playing', () => { + render( + + + + ); + + const renderedSoundTest = screen.getByTestId('soundTest'); + act(() => { + renderedSoundTest.click(); + }); + + const displayedText = screen.getByText('Stop testing'); + expect(displayedText).toBeInTheDocument(); + }); + + it('applies custom label classes', () => { + render( + + + + ); + + expect(screen.getByTestId('soundTestLabel')).toHaveClass('text-vera-body-extended'); + }); + + it('does not throw if setSinkId is undefined', () => { + global.Audio = vi.fn().mockImplementation(() => ({ + play: playMock, + pause: pauseMock, + currentTime: 9001, + setSinkId: undefined, + })); + + render( + + + + ); + + const renderedSoundTest = screen.getByTestId('soundTest'); + act(() => { + renderedSoundTest.click(); + }); + + const displayedText = screen.getByText('Stop testing'); + expect(displayedText).toBeInTheDocument(); + }); + + it('stops playing the sound when user clicks away', async () => { + render( + + + + ); + + const renderedSoundTest = screen.getByTestId('soundTest'); + act(() => { + renderedSoundTest.click(); + }); + + expect(playMock).toHaveBeenCalledOnce(); + + await userEvent.click(document.body); + expect(pauseMock).toHaveBeenCalledOnce(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.tsx new file mode 100644 index 00000000..85276c49 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/SoundTest.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Box from '@mui/material/Box'; +import { mediaDevices$ } from '@core/stores'; +import { twMerge } from 'tailwind-merge'; + +export type SoundTestProps = { + children: ReactElement; + labelClassName?: string; +}; + +/** + * SoundTest + * + * Renders a clickable row to test the speakers by playing a sound through the active audio output device. + * @param {SoundTestProps} props - The props for the component. + * @property {ReactElement} children - The icon to be rendered for the sound test. + * @property {string} labelClassName - Additional Tailwind classes for the label. + * @returns {ReactElement} The SoundTest component + */ +const SoundTest = ({ children, labelClassName }: SoundTestProps): ReactElement => { + const { t } = useTranslation(); + const [audioIsPlaying, setAudioIsPlaying] = useState(false); + const audioElement = useMemo(() => new Audio('/assets/sound.mp3'), []); + const currentAudioOutputDevice = mediaDevices$.useDeviceId('audiooutput'); + + const stopAudio = useCallback(() => { + audioElement.pause(); + + // eslint-disable-next-line react-hooks/immutability + audioElement.currentTime = 0; + setAudioIsPlaying(false); + }, [audioElement]); + + useEffect(() => { + if (currentAudioOutputDevice) { + void audioElement.setSinkId?.(currentAudioOutputDevice); + } + }, [audioElement, currentAudioOutputDevice]); + + const handlePlayAudio = useCallback(() => { + if (!audioIsPlaying) { + void audioElement.play(); + setAudioIsPlaying(true); + } else { + // Stop playing the audio and reset the playback to the beginning of the track. + stopAudio(); + } + }, [audioElement, audioIsPlaying, stopAudio]); + + return ( + stopAudio()}> + + {children} + + {!audioIsPlaying ? t('soundTest.start') : t('soundTest.stop')} + + + + ); +}; + +export default SoundTest; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/index.tsx new file mode 100644 index 00000000..99a75c2e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/SoundTest/index.tsx @@ -0,0 +1,3 @@ +import SoundTest from './SoundTest'; + +export default SoundTest; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.spec.tsx new file mode 100644 index 00000000..fb778f43 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.spec.tsx @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Subscriber as OTSubscriber } from '@vonage/client-sdk-video'; +import { Box } from 'opentok-layout-js'; +import { SubscriberWrapper } from '../../types/session'; +import Subscriber from './Subscriber'; + +describe('Subscriber', () => { + afterEach(() => { + cleanup(); + }); + + const createSubscriberWrapper = ( + id: string, + isScreenshare: boolean, + isPinned: boolean = false + ): SubscriberWrapper => { + const videoType = isScreenshare ? 'screen' : 'camera'; + return { + id, + element: document.createElement('video'), + isScreenshare, + isPinned, + subscriber: { + on: vi.fn(), + off: vi.fn(), + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + stream: { + streamId: id, + videoType, + }, + } as unknown as OTSubscriber, + }; + }; + + const createMockBox = (height: number, left: number, top: number, width: number): Box => { + return { + height, + left, + top, + width, + }; + }; + + it('should contain an audio indicator component if it is not a screenshare', () => { + const mockedSubscriberId = '123'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, false); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + expect(screen.getByTestId(`subscriber-container-${mockedSubscriberId}`)).toBeVisible(); + expect(screen.getByTestId('audio-indicator')).toBeVisible(); + }); + + it('should not contain an audio indicator component if it is a screenshare', () => { + const mockedSubscriberId = '456'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, true); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + expect(screen.getByTestId(`subscriber-container-${mockedSubscriberId}`)).toBeVisible(); + expect(screen.queryByTestId('audio-indicator')).not.toBeInTheDocument(); + }); + + it('should render a pin icon if the subscriber is pinned', () => { + const mockedSubscriberId = 'OT_7a0a1bfd-2892-4f5e-90e0-33dafdc7c373'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, false, true); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + expect(screen.getByTestId(`subscriber-container-${mockedSubscriberId}`)).toBeVisible(); + expect(screen.queryByTestId('pin-button')).toBeVisible(); + }); + + it('should show pin icon when subscriber is hovered', async () => { + const mockedSubscriberId = 'OT_7a0a1bfd-2892-4f5e-90e0-33dafdc7c373'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, false, false); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + const subscriberContainer = screen.getByTestId(`subscriber-container-${mockedSubscriberId}`); + expect(screen.queryByTestId('pin-button')).not.toBeInTheDocument(); + await act(() => userEvent.hover(subscriberContainer)); + expect(screen.getByTestId('pin-button')).toBeVisible(); + }); + + it('should show unpin icon when subscriber is hovered', async () => { + const mockedSubscriberId = 'OT_7a0a1bfd-2892-4f5e-90e0-33dafdc7c373'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, false, true); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + const subscriberContainer = screen.getByTestId(`subscriber-container-${mockedSubscriberId}`); + expect(screen.getByTestId('vivid-icon-pin-2-solid')).toBeVisible(); + await act(() => userEvent.hover(subscriberContainer)); + expect(screen.getByTestId('vivid-icon-pin-2-off-solid')).toBeVisible(); + }); + + it('should not render pin icon when screenshare subscriber is hovered', () => { + const mockedSubscriberId = 'OT_7a0a1bfd-2892-4f5e-90e0-33dafdc7c373'; + const subscriberWrapper = createSubscriberWrapper(mockedSubscriberId, true); + const mockedBox = createMockBox(10, 10, 10, 10); + + render( + + ); + + expect(screen.getByTestId(`subscriber-container-${mockedSubscriberId}`)).toBeVisible(); + expect(screen.queryByTestId('pin-button')).not.toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.tsx new file mode 100644 index 00000000..0ed7a18e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/Subscriber.tsx @@ -0,0 +1,162 @@ +import { MouseEvent, ReactElement, useEffect, useRef, useState } from 'react'; +import { Box } from 'opentok-layout-js'; +import { SubscriberWrapper } from '../../types/session'; +import AudioIndicator from '../MeetingRoom/AudioIndicator'; +import useSubscriberTalking from '../../hooks/useSubscriberTalking'; +import AvatarInitials from '../AvatarInitials'; +import ScreenShareNameDisplay from '../ScreenShareNameDisplay'; +import NameDisplay from '../MeetingRoom/NameDisplay'; +import VideoTile from '../MeetingRoom/VideoTile'; +import PinButton from '../MeetingRoom/PinButton'; +import useSessionContext from '../../hooks/useSessionContext'; +import isMouseEventInsideBox from '../../utils/isMouseEventInsideBox'; +import ScreenshareVideoTile from '../MeetingRoom/ScreenshareVideoTile'; +import { ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE } from '@utils/constants'; +import toRemValue from '@common/helpers/toRemValue'; +import attempt from '@common/execution/attempt'; + +export type SubscriberProps = { + subscriberWrapper: SubscriberWrapper; + isHidden: boolean; + box: Box | undefined; + isActiveSpeaker: boolean; +}; + +/** + * Subscriber Component + * + * Displays the Subscriber with an accompanying name tag. An audio indicator is displayed for non-screenshare subscribers, notifying meeting participants when the subscriber is publishing audio. + * For each subscriber, the initials are displayed if `publishVideo` is off. + * @param {SubscriberProps} props - The props for the component. + * @property {SubscriberWrapper} subscriberWrapper - The SubscriberWrapper for the Subscriber. + * @property {boolean} isHidden - Whether the participant is hidden. + * @property {Box | undefined} box - The Box of the parent element. + * @property {boolean} isActiveSpeaker - Whether the participant is the active speaker. + * @returns {ReactElement} - The Subscriber component. + */ +const Subscriber = ({ + subscriberWrapper, + isHidden, + box, + isActiveSpeaker, +}: SubscriberProps): ReactElement => { + const { isMaxPinned, pinSubscriber } = useSessionContext(); + const { isPinned, subscriber } = subscriberWrapper; + const isScreenShare = subscriber?.stream?.videoType === 'screen'; + const subRef = useRef(null); + const isTalking = useSubscriberTalking({ subscriber, isActiveSpeaker }); + const [isTileHovered, setIsTileHovered] = useState(false); + + useEffect(() => { + // If hidden - Unsubscribe from video to save bandwidth and cpu + // If not hidden - re-subscribe to video + void attempt(() => { + subscriberWrapper.subscriber.subscribeToVideo(!isHidden); + }); + }, [isHidden, subscriberWrapper.subscriber]); + + useEffect(() => { + if (subscriberWrapper && subRef.current) { + const { element } = subscriberWrapper; + + // eslint-disable-next-line react-hooks/immutability + element.id = subscriberWrapper.id; + element.classList.add('video__element', 'rounded-vera-large'); + + element.style.width = '100%'; + element.style.height = '100%'; + element.style.position = 'absolute'; + element.style.objectFit = 'contain'; + + subRef.current.appendChild(element); + } + }, [subscriberWrapper, isScreenShare]); + + const handlePinClick = (clickEvent: MouseEvent) => { + pinSubscriber(subscriberWrapper.id); + // We set hovering to false manually since onMouseLeave is not invoked when the DOM Element is moved. + setIsTileHovered(false); + // In case the DOM Element didn't move, which can happen if pinning while viewing screenshare - + // we use setTimeout to let the new layout render, then check if the element is still under the click event location. + // If so we re-enable the hover state. + setTimeout(() => { + if (subRef.current) { + const divRect = subRef.current.getBoundingClientRect(); + if (isMouseEventInsideBox(clickEvent, divRect)) { + setIsTileHovered(true); + } + } + }, 0); + }; + + const hasVideo = subscriberWrapper.subscriber?.stream?.hasVideo; + const initials = subscriberWrapper.subscriber?.stream?.initials; + const username = subscriberWrapper.subscriber?.stream?.name ?? ''; + const hasAudio = subscriberWrapper.subscriber.stream?.hasAudio; + const audioIndicatorStyle: React.CSSProperties = { + position: 'absolute', + top: toRemValue(ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE), + right: toRemValue(ABSOLUTE_DISTANCE_THRESHOLD_REM_VALUE), + height: '1.5rem', + width: '1.5rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: 'auto', + }; + + return isScreenShare ? ( + setIsTileHovered(true)} + onMouseLeave={() => setIsTileHovered(false)} + > + {box && } + + ) : ( + setIsTileHovered(true)} + onMouseLeave={() => setIsTileHovered(false)} + > + + + + {!hasVideo && ( + + )} + {box && } + + ); +}; + +export default Subscriber; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/index.tsx new file mode 100644 index 00000000..49ffbea7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/Subscriber/index.tsx @@ -0,0 +1,3 @@ +import Subscriber from './Subscriber'; + +export default Subscriber; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx new file mode 100644 index 00000000..e7c806ba --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx @@ -0,0 +1,60 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import ListItem from '@mui/material/ListItem'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; +import VividIcon from '@ui/VividIcon'; +import { Typography } from '@mui/material'; +import Separator from '@components/Separator'; +import Box from '@mui/material/Box'; + +export type SupportedBrowserListItemProps = { + url: string; + browser: string; +}; + +/** + * Displays a list item for a specified browser, including a button to open its download URL. + * @param {SupportedBrowserListItemProps} props - The props for the component + * @returns {ReactElement} - The rendered component. + */ +const SupportedBrowserListItem = ({ + url, + browser, +}: SupportedBrowserListItemProps): ReactElement => { + const { t } = useTranslation(); + + return ( + + + + + + + {t('unsupportedBrowser.supported.visitPage')} + + + } + > + {browser}} /> + + + + ); +}; + +export default SupportedBrowserListItem; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/index.tsx new file mode 100644 index 00000000..3ecd0af8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/index.tsx @@ -0,0 +1,3 @@ +import SupportedBrowserListItem from './SupportedBrowserListItem'; + +export default SupportedBrowserListItem; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx new file mode 100644 index 00000000..a3303f33 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx @@ -0,0 +1,43 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import List from '@mui/material/List'; +import Typography from '@mui/material/Typography'; +import { SUPPORTED_BROWSERS } from '../../../utils/constants'; +import SupportedBrowserListItem from '../SupportedBrowserListItem'; +import Card from '@ui/Card'; + +/** + * SupportedBrowsers Component + * + * This component delineates all of the supported browsers for the Vonage Video API Reference App. + * @returns {ReactElement} The SupportedBrowsers component. + */ +const SupportedBrowsers = (): ReactElement => { + const { t } = useTranslation(); + + return ( + + + {t('unsupportedBrowser.supported.title')} + + + {SUPPORTED_BROWSERS.map(({ browser, link }) => { + return ; + })} + + + ); +}; + +export default SupportedBrowsers; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/index.tsx new file mode 100644 index 00000000..a1752de0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/index.tsx @@ -0,0 +1,3 @@ +import SupportedBrowsers from './SupportedBrowsers'; + +export default SupportedBrowsers; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx new file mode 100644 index 00000000..46fab4a7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx @@ -0,0 +1,50 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +/** + * UnsupportedBrowserMessage Component + * + * This component warns users they are using a browser unsupported by the Vonage Video API Reference App. + * @returns {ReactElement} The UnsupportedBrowserMessage component. + */ +const UnsupportedBrowserMessage = (): ReactElement => { + const { t } = useTranslation(); + + return ( + + + {t('unsupportedBrowser.header')} + + + {t('unsupportedBrowser.message')} + + + ); +}; + +export default UnsupportedBrowserMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/index.tsx new file mode 100644 index 00000000..fbaf1dbe --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/index.tsx @@ -0,0 +1,3 @@ +import UnsupportedBrowserMessage from './UnsupportedBrowserMessage'; + +export default UnsupportedBrowserMessage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/VividIcon/VividIcon.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/VividIcon/VividIcon.tsx new file mode 100644 index 00000000..ad3697ff --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/VividIcon/VividIcon.tsx @@ -0,0 +1,3 @@ +import VividIcon from '@ui/VividIcon'; + +export default VividIcon; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx new file mode 100644 index 00000000..a53a8f67 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.spec.tsx @@ -0,0 +1,65 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ReactElement } from 'react'; +import { makeTestProvider } from '@test/providers'; +import BackgroundEffectsButton from './BackgroundEffectsButton'; +import { env } from '../../../../env'; + +const { mockHasMediaProcessorSupport } = vi.hoisted(() => { + return { + mockHasMediaProcessorSupport: vi.fn().mockReturnValue(true), + }; +}); +vi.mock('@vonage/client-sdk-video', () => ({ + hasMediaProcessorSupport: mockHasMediaProcessorSupport, +})); + +describe('BackgroundEffectsButton', () => { + const mockOnClick = vi.fn(); + beforeEach(() => { + vi.clearAllMocks(); + env.partialUpdate({ + ALLOW_BACKGROUND_EFFECTS: true, + }); + }); + + it('renders the button if media processor is supported', () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + render(); + expect(screen.getByLabelText(/video effects/i)).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('does not render the button if media processor is not supported', () => { + mockHasMediaProcessorSupport.mockReturnValue(false); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls onClick when the button is clicked', async () => { + mockHasMediaProcessorSupport.mockReturnValue(true); + render(); + await userEvent.click(screen.getByRole('button')); + expect(mockOnClick).toHaveBeenCalled(); + }); + + it('is not rendered when background effects are not allowed', () => { + env.partialUpdate({ + ALLOW_BACKGROUND_EFFECTS: false, + }); + + render(); + + expect(screen.queryByLabelText(/video effects/i)).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper, ...context } = makeTestProvider([]); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx new file mode 100644 index 00000000..9bea7afe --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/BackgroundEffectsButton.tsx @@ -0,0 +1,64 @@ +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import { ReactElement } from 'react'; +import PortraitIcon from '@mui/icons-material/Portrait'; +import { useTranslation } from 'react-i18next'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { VIDEO_CONTAINER_BUTTON_SIZE_WR } from '@utils/constants'; +import VideoContainerButton from '../../VideoContainerButton'; +import { env } from '../../../../env'; + +export type BackgroundEffectsButtonProps = { + onClick: () => void; +}; + +/** + * BackgroundEffectsButton Component + * + * If the user's device supports the Vonage Media Processor, displays a button to modify background effects. + * @param {BackgroundEffectsButtonProps} props - The props for the component. + * @property {Function} onClick - Function to call when the button is clicked. + * @returns {ReactElement | false} - The BackgroundEffectsButton component. + */ +const BackgroundEffectsButton = ({ + onClick, +}: BackgroundEffectsButtonProps): ReactElement | false => { + const shouldDisplayBackgroundEffects = + hasMediaProcessorSupport('both') && env.ALLOW_BACKGROUND_EFFECTS; + const { t } = useTranslation(); + + return ( + shouldDisplayBackgroundEffects && ( + + + + } + /> + + + ) + ); +}; + +export default BackgroundEffectsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx new file mode 100644 index 00000000..d1cbe7ff --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsButton/index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsButton from './BackgroundEffectsButton'; + +export default BackgroundEffectsButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx new file mode 100644 index 00000000..026a3505 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.spec.tsx @@ -0,0 +1,107 @@ +import { render as renderBase, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeTestProvider, providers } from '@test/providers'; +import type { ProviderOptions } from '@test/providers'; +import BackgroundEffectsDialog from './BackgroundEffectsDialog'; +import { setupWindowNavigatorMock, makeMediaStreamMock } from '@web-test/fixtures'; + +describe('BackgroundEffectsDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + getUserMedia: Promise.resolve(makeMediaStreamMock({})), + getDisplayMedia: Promise.resolve(makeMediaStreamMock({})), + getSupportedConstraints: vi.fn().mockReturnValue({}), + }, + }); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders dialog when open', async () => { + render( + {}} /> + ); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + expect(screen.getByText(/video effects/i)).toBeInTheDocument(); + }); + + it('does not render dialog when closed', async () => { + const { queryByRole } = render( + {}} + /> + ); + await waitFor(() => { + expect(queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('calls setBackgroundEffectsOpen(false) on close', async () => { + const setBackgroundEffectsOpen = vi.fn(); + render( + + ); + const backdrop = document.querySelector('.MuiBackdrop-root'); + + if (!backdrop) { + throw new Error('Backdrop not found'); + } + + expect(backdrop).toBeTruthy(); + + await userEvent.click(backdrop); + + await waitFor(() => { + expect(setBackgroundEffectsOpen).toHaveBeenCalledWith(false); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; +}; + +function render( + ui: ReactElement, + { userContext, sessionContext, publisherContext, backgroundPublisherContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx new file mode 100644 index 00000000..a118482c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx @@ -0,0 +1,71 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; +import DialogContent from '@mui/material/DialogContent'; +import VividIcon from '@ui/VividIcon'; +import Typography from '@mui/material/Typography'; +import BackgroundEffectsLayout from '../../../BackgroundEffects/BackgroundEffectsLayout'; + +export type BackgroundEffectsDialogProps = { + isBackgroundEffectsOpen: boolean; + setIsBackgroundEffectsOpen: (open: boolean) => void; +}; + +/** + * BackgroundEffectsDialog Component + * + * This component renders a dialog for background effects in the waiting room. + * @param {BackgroundEffectsDialogProps} props - The props for the component. + * @property {boolean} isBackgroundEffectsOpen - Whether the dialog is open. + * @property {Function} setIsBackgroundEffectsOpen - Function to set the open state of the dialog. + * @returns {ReactElement} The background effects dialog component. + */ +const BackgroundEffectsDialog = ({ + isBackgroundEffectsOpen, + setIsBackgroundEffectsOpen, +}: BackgroundEffectsDialogProps): ReactElement | false => { + const handleClose = () => { + setIsBackgroundEffectsOpen(false); + }; + const { t } = useTranslation(); + + return ( + + + + {t('backgroundEffects.title')} + + + + + + + + + + ); +}; + +export default BackgroundEffectsDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx new file mode 100644 index 00000000..7d9a9efd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectsDialog from './BackgroundEffectsDialog'; + +export default BackgroundEffectsDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx new file mode 100644 index 00000000..2ebb6914 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.spec.tsx @@ -0,0 +1,183 @@ +import { render as renderBase, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactElement } from 'react'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import type { PreviewPublisherContextType } from '@Context/PreviewPublisherProvider'; +import type { BackgroundPublisherContextType } from '@Context/BackgroundPublisherProvider'; +import CameraButton from './CameraButton'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import composeProviders from '@web/helpers/composeProviders'; +import { + setupWindowNavigatorMock, + makeMediaStreamMock, + makeMediaDeviceInfos, +} from '@web-test/fixtures'; +import { mediaDevices$ } from '@core/stores'; +import { env } from '../../../env'; + +const mockDevices = makeMediaDeviceInfos(); + +type PreviewPublisherContextWithMock = PreviewPublisherContextType & { + _previewToggleMockApplied?: boolean; +}; + +type BackgroundPublisherContextWithMock = BackgroundPublisherContextType & { + _backgroundToggleMockApplied?: boolean; +}; + +describe('CameraButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + env.partialUpdate({ + ALLOW_CAMERA_CONTROL: true, + }); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(mockDevices), + getUserMedia: Promise.resolve(makeMediaStreamMock({})), + }, + }); + + // Initialize the mediaDevices$ store with mock devices to prevent useSyncPublisherDevices from disabling video + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: mockDevices, + })); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders the video on icon when video is enabled', async () => { + render(, { + previewPublisherContext: { + __onCreated: (context) => { + context.isVideoEnabled = true; + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-video-line')).toBeInTheDocument(); + }); + }); + + it('renders the video off icon when video is disabled', async () => { + render(, { + previewPublisherContext: { + __onCreated: (context) => { + context.isVideoEnabled = false; + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-video-off-line')).toBeInTheDocument(); + }); + }); + + it('updates the main publisher and the background replacement publisher when clicked', async () => { + const previewToggleMock = vi.fn(); + const backgroundToggleMock = vi.fn(); + + render(, { + previewPublisherContext: { + __interceptor: (context) => { + const contextWithMock = context as PreviewPublisherContextWithMock; + if (!contextWithMock._previewToggleMockApplied) { + const originalToggle = context.toggleVideo.bind(context); + context.toggleVideo = () => { + previewToggleMock(); + return originalToggle(); + }; + contextWithMock._previewToggleMockApplied = true; + } + }, + }, + backgroundPublisherContext: { + __interceptor: (context) => { + const contextWithMock = context as BackgroundPublisherContextWithMock; + if (!contextWithMock._backgroundToggleMockApplied) { + const originalToggle = context.toggleVideo.bind(context); + context.toggleVideo = () => { + backgroundToggleMock(); + return originalToggle(); + }; + contextWithMock._backgroundToggleMockApplied = true; + } + }, + }, + }); + + await waitFor(() => { + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(previewToggleMock).toHaveBeenCalled(); + expect(backgroundToggleMock).toHaveBeenCalled(); + }); + }); + + it('is not rendered when allowCameraControl is false', async () => { + env.partialUpdate({ + ALLOW_CAMERA_CONTROL: false, + }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('camera-button-wrapper')).not.toBeInTheDocument(); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; +}; + +function render( + ui: ReactElement, + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + previewPublisherContext, + }: RenderOptions = {} +) { + const { wrapper: ButtonWrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.previewPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + previewPublisherContext, + runtimeContext: undefined, + } + ); + + const wrapper = composeProviders(SuspenseBoundary, ButtonWrapper); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx new file mode 100644 index 00000000..86469157 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/CameraButton.tsx @@ -0,0 +1,90 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import useBackgroundPublisherContext from '@hooks/useBackgroundPublisherContext'; +import { VIDEO_CONTAINER_BUTTON_SIZE_WR } from '@utils/constants'; +import Tooltip from '@mui/material/Tooltip'; +import Box from '@mui/material/Box'; +import VividIcon from '@ui/VividIcon'; +import VideoContainerButton from '../VideoContainerButton'; +import { env } from '../../../env'; + +/** + * CameraButton Component + * + * Displays an overlay button to handle toggling video on and off for the preview publisher. + * @returns {ReactElement | false} - The CameraButton component. + */ +const CameraButton = (): ReactElement | false => { + const { t } = useTranslation(); + const { isVideoEnabled, toggleVideo } = usePreviewPublisherContext(); + const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); + + const title = isVideoEnabled + ? t('devices.video.camera.state.off') + : t('devices.video.camera.state.on'); + + const handleToggleVideo = () => { + toggleVideo(); + toggleBackgroundVideoPublisher(); + }; + + return ( + env.ALLOW_CAMERA_CONTROL && ( + + + + ) : ( + + ) + } + /> + + + ) + ); +}; + +export default CameraButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/index.tsx new file mode 100644 index 00000000..cab488cc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/CameraButton/index.tsx @@ -0,0 +1,3 @@ +import CameraButton from './CameraButton'; + +export default CameraButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx new file mode 100644 index 00000000..dc97132c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.spec.tsx @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, screen, render as renderBase } from '@testing-library/react'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import backgroundEffectsDialog$ from '@Context/BackgroundEffectsDialog'; +import precallNetworkTestDialog$ from '@Context/PrecallNetworkTestDialog'; +import ControlPanel from '.'; +import composeProviders from '@web/helpers/composeProviders'; +import SuspenseBoundary from '@web/components/SuspenseBoundary'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +describe('ControlPanel', () => { + beforeEach(() => { + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + + Object.defineProperty(global.navigator, 'permissions', { + writable: true, + value: { + query: vi.fn().mockResolvedValue({ state: 'granted' }), + }, + }); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it('should render', () => { + render( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput={false} + openAudioOutput={false} + anchorEl={null} + /> + ); + + expect(screen.getByTestId('ControlPanel')).toBeVisible(); + }); + + it('should display open audio input devices menu', () => { + const { rerender } = render( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput={false} + openAudioOutput={false} + anchorEl={null} + /> + ); + + expect(screen.queryByTestId('audioinput-menu')).not.toBeInTheDocument(); + rerender( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput + openVideoInput={false} + openAudioOutput={false} + anchorEl={null} + /> + ); + expect(screen.getByTestId('audioinput-menu')).toBeVisible(); + }); + + it('should open video input devices menu', () => { + const { rerender } = render( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput={false} + openAudioOutput={false} + anchorEl={null} + /> + ); + + expect(screen.queryByTestId('videoinput-menu')).not.toBeInTheDocument(); + rerender( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput + openAudioOutput={false} + anchorEl={null} + /> + ); + expect(screen.getByTestId('videoinput-menu')).toBeVisible(); + }); + + it('should open audio output devices menu', () => { + const { rerender } = render( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput={false} + openAudioOutput={false} + anchorEl={null} + /> + ); + + expect(screen.queryByTestId('audiooutput-menu')).not.toBeInTheDocument(); + rerender( + {}} + handleVideoInputOpen={() => {}} + handleAudioOutputOpen={() => {}} + handleClose={() => {}} + openAudioInput={false} + openVideoInput={false} + openAudioOutput + anchorEl={null} + /> + ); + expect(screen.getByTestId('audiooutput-menu')).toBeVisible(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; + backgroundPublisherContext?: ProviderOptions['BackgroundPublisherContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; +}; + +function render( + ui: ReactElement, + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + previewPublisherContext, + }: RenderOptions = {} +) { + const { wrapper: ControlPanelWrapper, ...context } = makeTestProvider( + [ + providers.user, + providers.session, + providers.publisher, + providers.backgroundPublisher, + providers.previewPublisher, + providers.runtime, + ], + { + userContext, + sessionContext, + publisherContext, + backgroundPublisherContext, + previewPublisherContext, + runtimeContext: undefined, + } + ); + + const wrapper = composeProviders( + SuspenseBoundary, + ControlPanelWrapper, + backgroundEffectsDialog$.Provider, + precallNetworkTestDialog$.Provider + ); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx new file mode 100644 index 00000000..851a4276 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/ControlPanel.tsx @@ -0,0 +1,187 @@ +import { ReactElement, MouseEvent, TouchEvent, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useIsSmallViewport from '@hooks/useIsSmallViewport'; +import Box from '@mui/material/Box'; +import type { SxProps } from '@mui/material'; +import VividIcon from '@ui/VividIcon'; +import ButtonBase from '@mui/material/ButtonBase'; +import MenuDevices from '../MenuDevices'; +import MenuMoreOptions from '../MenuMoreOptions/MenuMoreOptions'; + +const textSx: SxProps = { + flex: '1 1 0', + minWidth: 0, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +export type ControlPanelProps = { + handleAudioInputOpen: ( + event: MouseEvent | TouchEvent + ) => void; + handleVideoInputOpen: ( + event: MouseEvent | TouchEvent + ) => void; + handleAudioOutputOpen: ( + event: MouseEvent | TouchEvent + ) => void; + handleClose: () => void; + openVideoInput: boolean; + openAudioInput: boolean; + openAudioOutput: boolean; + anchorEl: HTMLElement | null; +}; + +/** + * ControlPanel Component + * + * Displays drop-down menus to change the user's audio input, audio output, and video input devices. + * @param {ControlPanelProps} props - The props for the component. + * @property {Function} handleAudioInputOpen - Function to open the audio input menu. + * @property {Function} handleVideoInputOpen - Function to open the video input menu. + * @property {Function} handleAudioOutputOpen - Function to open the audio output menu. + * @property {() => void} handleClose - Function to close the menu. + * @property {boolean} openVideoInput - Whether the video input menu is open. + * @property {boolean} openAudioInput - Whether the audio input menu is open. + * @property {boolean} openAudioOutput- Whether the audio output menu is open. + * @property {HTMLElement | null} anchorEl - The reference element for the ControlPanel component. + * @returns {ReactElement} - The ControlPanel component. + */ +const ControlPanel = ({ + handleAudioInputOpen, + handleVideoInputOpen, + handleAudioOutputOpen, + handleClose, + openVideoInput, + openAudioInput, + openAudioOutput, + anchorEl, +}: ControlPanelProps): ReactElement | false => { + const [openMoreOptions, setOpenMoreOptions] = useState(false); + const [moreOptionsAnchorEl, setMoreOptionsAnchorEl] = useState(null); + const handleCloseMoreOptions = () => { + setOpenMoreOptions(false); + setMoreOptionsAnchorEl(null); + }; + const handleOpenMoreOptions = (event: MouseEvent) => { + setMoreOptionsAnchorEl(event.currentTarget); + setOpenMoreOptions(true); + }; + + const { t } = useTranslation(); + const isSmallViewport = useIsSmallViewport(); + + const buttonSx: SxProps = { + display: 'flex', + alignItems: 'center', + gap: 1.5, + }; + + return ( + + + + + + {isSmallViewport + ? t('devices.audio.microphone.short') + : t('devices.audio.microphone.full')} + + + + + + + + + + {t('button.camera')} + + + + + + + + + + {t('button.speaker')} + + + + + + + + + + + + + ); +}; + +export default ControlPanel; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/index.tsx new file mode 100644 index 00000000..8a8e39a9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/ControlPanel/index.tsx @@ -0,0 +1,3 @@ +import ControlPanel from './ControlPanel'; + +export default ControlPanel; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.spec.tsx new file mode 100644 index 00000000..1fcd82b3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.spec.tsx @@ -0,0 +1,203 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { makeMediaDeviceInfos, setupWindowNavigatorMock } from '@web-test/fixtures'; + +import MenuDevices from './MenuDevices'; +import * as util from '@utils/util'; +import mediaDevices$ from '@core/stores/devices'; + +const someDevices = makeMediaDeviceInfos(); + +describe('MenuDevices Component', () => { + beforeEach(() => { + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + dispatchEvent: vi.fn().mockReturnValue(true), + enumerateDevices: Promise.resolve(someDevices), + removeEventListener: vi.fn(), + }, + permissions: { + query: Promise.resolve({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + state: 'granted', + } as unknown as PermissionStatus), + }, + }); + + mediaDevices$.reset(); + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices, + })); + }); + + afterEach(() => { + act(() => { + mediaDevices$.reset(); + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices, + })); + }); + }); + + it('calls selectDevice and onClose when device is clicked', async () => { + const user = userEvent.setup(); + const mockOnClose = vi.fn(); + const anchorEl = document.createElement('div'); + + const kind = 'audioinput'; + const audioInputDevices = someDevices.filter((d) => d.kind === kind); + const secondDevice = audioInputDevices[1]; + + const selectDeviceSpy = vi + .spyOn(mediaDevices$.actions, 'selectDevice') + .mockResolvedValue(undefined as never); + + render(); + + await user.click(screen.getByTestId(`${kind}-menu-item-${secondDevice.deviceId}`)); + + expect(selectDeviceSpy).toHaveBeenCalledWith(kind, secondDevice.deviceId); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('does not render audio output devices when browser does not support it', async () => { + vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(false); + + const mockOnClose = vi.fn(); + const anchorEl = document.createElement('div'); + + const kind = 'audiooutput'; + const audioOutputDevices = someDevices.filter((d) => d.kind === kind); + const firstDevice = audioOutputDevices[0]; + const secondDevice = audioOutputDevices[1]; + + render(); + + await waitFor(() => { + expect( + screen.queryByTestId(`${kind}-menu-item-${firstDevice.deviceId}`) + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId(`${kind}-menu-item-${secondDevice.deviceId}`) + ).not.toBeInTheDocument(); + }); + }); + + it('only renders devices of the specified kind', () => { + vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(true); + + testDeviceKindRendering('audioinput'); + testDeviceKindRendering('audiooutput'); + testDeviceKindRendering('videoinput'); + }); + + it('renders SoundTest when audiooutput devices are available', async () => { + vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(true); + + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); + vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); + + const anchorEl = document.createElement('div'); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('soundTest')).toBeInTheDocument(); + }); + }); + + it('does not render SoundTest when no audiooutput devices are available', async () => { + vi.spyOn(util, 'isGetActiveAudioOutputDeviceSupported').mockReturnValue(true); + + act(() => { + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: someDevices.filter((d) => d.kind !== 'audiooutput'), + audiooutput: undefined, + })); + }); + + const anchorEl = document.createElement('div'); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByTestId('soundTest')).not.toBeInTheDocument(); + }); + }); + + it('renders an empty state when no devices are available', async () => { + const mockOnClose = vi.fn(); + const anchorEl = document.createElement('div'); + + act(() => { + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: [], + audioinput: undefined, + })); + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('audioinput-menu-empty-state')).toBeInTheDocument(); + expect(screen.getByText('No devices found')).toBeInTheDocument(); + }); + }); +}); + +function testDeviceKindRendering(kind: MediaDeviceKind) { + const mockOnClose = vi.fn(); + const anchorEl = document.createElement('div'); + const devicesOfKind = someDevices.filter((d) => d.kind === kind); + const firstDevice = devicesOfKind[0]; + + // Set the first device as selected in the store for this kind + act(() => { + mediaDevices$.setState((state) => ({ + ...state, + [kind]: firstDevice.deviceId, + })); + }); + + const { unmount } = render( + + ); + + devicesOfKind.forEach((device) => { + const element = screen.getByTestId(`${kind}-menu-item-${device.deviceId}`); + expect(element).toBeInTheDocument(); + + const shouldBeSelected = device.deviceId === firstDevice.deviceId; + + if (shouldBeSelected) { + expect(element).toHaveClass('Mui-selected'); + } + + if (!shouldBeSelected) { + expect(element).not.toHaveClass('Mui-selected'); + } + }); + + someDevices + .filter((d) => d.kind !== kind) + .forEach((device) => { + expect( + screen.queryByTestId(`${device.kind}-menu-item-${device.deviceId}`) + ).not.toBeInTheDocument(); + }); + + unmount(); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.tsx new file mode 100644 index 00000000..a10735d7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/MenuDevices.tsx @@ -0,0 +1,105 @@ +import { ReactElement, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import MenuItem from '@mui/material/MenuItem'; +import Menu from '@mui/material/Menu'; +import VividIcon from '@ui/VividIcon'; +import Box from '@mui/material/Box'; +import cleanAndDedupeDeviceLabels from '@utils/cleanAndDedupeDeviceLabels/cleanAndDedupeDeviceLabels'; +import SoundTest from '../../SoundTest'; +import { isGetActiveAudioOutputDeviceSupported } from '@utils/util'; +import mediaDevices$ from '@core/stores/devices'; +import useSelectDeviceHandler from '@hooks/useSelectDeviceHandler'; +import { MediaDeviceInfoJSON } from '@web/types'; +import { makeApplicationErrorMapper } from '@core/errors'; +import { handleClientApplicationError } from '@ui/helpers'; + +export type MenuDevicesWaitingRoomProps = { + onClose: () => void; + open: boolean; + mediaDeviceKind: MediaDeviceKind; + anchorEl: HTMLElement | null; +}; + +/** + * MenuDevices Component + * + * Displays a menu with the available media devices (audio input, audio output, video input) for the user to select + */ +const MenuDevices = ({ + mediaDeviceKind, + onClose, + open, + anchorEl, +}: MenuDevicesWaitingRoomProps): ReactElement => { + const { t } = useTranslation(); + + const devices = mediaDevices$.useMediaDevices( + mediaDeviceKind, + Object.values + ); + + const selectedDeviceId = mediaDevices$.useDeviceId(mediaDeviceKind); + + const processedDevices = useMemo(() => cleanAndDedupeDeviceLabels(devices), [devices]); + + const shouldDisplayDevices = + mediaDeviceKind !== 'audiooutput' || isGetActiveAudioOutputDeviceSupported(); + + const shouldDisplayEmptyState = shouldDisplayDevices && processedDevices.length === 0; + + const { handleSelectDevice } = useSelectDeviceHandler(); + + const applicationErrorMapper = makeApplicationErrorMapper(); + + return ( + + {shouldDisplayDevices && + processedDevices.map(({ deviceId, label }) => ( + { + try { + onClose(); + + await handleSelectDevice({ mediaDeviceKind, deviceId }); + } catch (error) { + handleClientApplicationError(applicationErrorMapper(error)); + } + }} + key={deviceId} + selected={deviceId === selectedDeviceId} + > + {label} + + ))} + + {shouldDisplayEmptyState && ( + + {t('waitingRoom.devices.noDevicesFound')} + + )} + + {mediaDeviceKind === 'audiooutput' && + (processedDevices.length > 0 ? ( + + + + + + ) : ( + + {t('waitingRoom.devices.noDevicesFound')} + + ))} + + ); +}; + +export default MenuDevices; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/index.tsx new file mode 100644 index 00000000..ae2cd7c0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuDevices/index.tsx @@ -0,0 +1,3 @@ +import MenuDevices from './MenuDevices'; + +export default MenuDevices; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.spec.tsx new file mode 100644 index 00000000..74e7e47f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.spec.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render as renderBase, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import * as clientSdkVideo from '@vonage/client-sdk-video'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import backgroundEffectsDialog$ from '@Context/BackgroundEffectsDialog'; +import precallNetworkTestDialog$ from '@Context/PrecallNetworkTestDialog'; +import composeProviders from '@web/helpers/composeProviders'; +import MenuMoreOptions from './MenuMoreOptions'; +import { env } from '../../../env'; + +describe('MenuMoreOptions', () => { + const mockOnClose = vi.fn(); + const mockAnchorEl = document.createElement('button'); + + beforeEach(() => { + env.reset(); + mockOnClose.mockReset(); + vi.spyOn(clientSdkVideo, 'hasMediaProcessorSupport').mockReturnValue(true); + }); + + afterEach(() => { + env.reset(); + }); + + it('should render when open is true', () => { + render(); + + expect(screen.getByTestId('menu-more-options')).toBeInTheDocument(); + }); + + it('should keep menu items as direct children of the menu list', () => { + render(); + + const menuListElement = screen.getByRole('menu'); + + expect(Array.from(menuListElement.children).every((child) => child.tagName === 'LI')).toBe( + true + ); + }); + + it('should not render menu items when open is false', () => { + render(); + + expect(screen.queryByText(/^settings$/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/video effects/i)).not.toBeInTheDocument(); + }); + + it('should not display advanced settings option when the flag is disabled', () => { + env.partialUpdate({ WAITING_ROOM_ALLOW_ADVANCED_SETTINGS: false }); + render(); + + expect(screen.queryByText(/^settings$/i)).not.toBeInTheDocument(); + }); + + it('should display advanced settings option when the flag is enabled', () => { + env.partialUpdate({ WAITING_ROOM_ALLOW_ADVANCED_SETTINGS: true }); + render(); + + expect(screen.getByText(/^settings$/i)).toBeInTheDocument(); + }); + + it('should open advanced settings and close the menu when clicking the option', async () => { + const user = userEvent.setup(); + + env.partialUpdate({ WAITING_ROOM_ALLOW_ADVANCED_SETTINGS: true }); + render(); + + const advancedSettingsMenuItem = screen.getByText(/^settings$/i); + + await user.click(advancedSettingsMenuItem); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + expect(advancedSettings$.getState().isOpen).toBe(true); + }); + + it('should display video effects option when media processor is supported', () => { + render(); + + expect(screen.getByText(/video effects/i)).toBeInTheDocument(); + }); + + it('should call onClose when clicking on video effects option', async () => { + const user = userEvent.setup(); + render(); + + const menuItem = screen.getByText(/video effects/i); + await user.click(menuItem); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should display icon for video effects', () => { + render(); + + expect(screen.getByTestId('vivid-icon-gallery-line')).toBeInTheDocument(); + }); + + it('should not display video effects option when media processor is not supported', () => { + vi.spyOn(clientSdkVideo, 'hasMediaProcessorSupport').mockReturnValue(false); + render(); + + expect(screen.getByText(/video effects/i).closest('li')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('should not display video effects option when background effects are not allowed', () => { + env.partialUpdate({ ALLOW_BACKGROUND_EFFECTS: false }); + render(); + + expect(screen.getByText(/video effects/i).closest('li')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('should still display precall network test when media processor is not supported', () => { + vi.spyOn(clientSdkVideo, 'hasMediaProcessorSupport').mockReturnValue(false); + render(); + + expect(screen.getByText(/pre-call network test/i)).toBeInTheDocument(); + expect(screen.getByText(/pre-call network test/i).closest('li')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('shows an unsupported-feature tooltip for disabled menu items', async () => { + const user = userEvent.setup(); + + vi.spyOn(clientSdkVideo, 'hasMediaProcessorSupport').mockReturnValue(false); + render(); + + const videoEffectsMenuItem = screen.getByRole('menuitem', { name: /video effects/i }); + + await user.hover(videoEffectsMenuItem); + + expect( + await screen.findByText(/your browser does not support this feature/i) + ).toBeInTheDocument(); + }); + + it('does not show the unsupported-feature tooltip when the menu first opens', () => { + vi.spyOn(clientSdkVideo, 'hasMediaProcessorSupport').mockReturnValue(false); + render(); + + expect( + screen.queryByText(/your browser does not support this feature/i) + ).not.toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const wrapper = composeProviders( + backgroundEffectsDialog$.Provider, + precallNetworkTestDialog$.Provider + ); + return renderBase(ui, { wrapper }); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.tsx new file mode 100644 index 00000000..91a51f6f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/MenuMoreOptions.tsx @@ -0,0 +1,173 @@ +import { useCallback, useState } from 'react'; +import type { FocusEvent, MouseEvent, ReactElement } from 'react'; +import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; +import MenuItem from '@mui/material/MenuItem'; +import type { MenuItemProps } from '@mui/material/MenuItem'; +import Menu from '@mui/material/Menu'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import { useTranslation } from 'react-i18next'; +import VividIcon from '@ui/VividIcon'; +import backgroundEffectsDialog$ from '@Context/BackgroundEffectsDialog'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import precallNetworkTestDialog$ from '@Context/PrecallNetworkTestDialog'; +import useStableRef from '@web/hooks/useStableRef'; +import { env } from '../../../env'; + +export type MenuMoreOptionsWaitingRoomProps = { + onClose: () => void; + open: boolean; + anchorEl: HTMLElement | null; +}; + +const { open: openAdvancedSettings } = advancedSettings$.actions; + +/** + * MenuMoreOptions Component + * + * Displays a list of options in the waiting room. + * @param {MenuMoreOptionsWaitingRoomProps} props - The props for the component. + * @property {Function} onClose - Menu close handler. + * @property {boolean} open - Whether the menu is open or not. + * @property {HTMLElement | null} anchorEl - The anchor element. + * @returns {ReactElement} - The MenuMoreOptions component + */ +const MenuMoreOptions = ({ + onClose, + open, + anchorEl, +}: MenuMoreOptionsWaitingRoomProps): ReactElement => { + const { t } = useTranslation(); + const hasSupportedMediaProcessor = hasMediaProcessorSupport('both'); + const isBackgroundEffectsSupported = hasSupportedMediaProcessor && env.ALLOW_BACKGROUND_EFFECTS; + const isPrecallNetworkTestSupported = hasSupportedMediaProcessor; + const unsupportedFeatureTooltipTitle = t('waitingRoom.unsupportedFeature.tooltip'); + const [tooltipAnchorElement, setTooltipAnchorElement] = useState(null); + const menuAutoFocusGuard = useStableRef( + () => { + const guard = { active: false, timeoutId: -1 }; + + if (!open) { + return guard; + } + + guard.active = true; + guard.timeoutId = window.setTimeout(() => { + guard.active = false; + }, 0); + + return guard; + }, + ({ timeoutId }) => window.clearTimeout(timeoutId), + [open] + ); + + const { open: openBackgroundEffects } = backgroundEffectsDialog$.use.actions(); + const { open: openPrecallNetworkTest } = precallNetworkTestDialog$.use.actions(); + + const handleMenuClose = useCallback(() => { + setTooltipAnchorElement(null); + onClose(); + }, [onClose]); + + const handleClickAdvancedSettings = useCallback(() => { + openAdvancedSettings(); + handleMenuClose(); + }, [handleMenuClose]); + + const handleClickBackgroundEffects = useCallback(() => { + openBackgroundEffects(); + handleMenuClose(); + }, [handleMenuClose, openBackgroundEffects]); + + const handleClickNetworkTest = useCallback(() => { + openPrecallNetworkTest(); + handleMenuClose(); + }, [handleMenuClose, openPrecallNetworkTest]); + + const handleOpenUnsupportedTooltip = useCallback( + (event: FocusEvent | MouseEvent) => { + if (event.type === 'focus' && menuAutoFocusGuard.current.active) { + return; + } + + setTooltipAnchorElement(event.currentTarget); + }, + [menuAutoFocusGuard] + ); + + const handleCloseUnsupportedTooltip = useCallback(() => { + setTooltipAnchorElement(null); + }, []); + + const backgroundEffectsAvailabilityProps: MenuItemProps = isBackgroundEffectsSupported + ? { + onClick: handleClickBackgroundEffects, + } + : { + 'aria-disabled': true as const, + className: 'cursor-not-allowed opacity-50', + onBlur: handleCloseUnsupportedTooltip, + onFocus: handleOpenUnsupportedTooltip, + onMouseEnter: handleOpenUnsupportedTooltip, + onMouseLeave: handleCloseUnsupportedTooltip, + }; + + const precallNetworkTestAvailabilityProps: MenuItemProps = isPrecallNetworkTestSupported + ? { + onClick: handleClickNetworkTest, + } + : { + 'aria-disabled': true as const, + className: 'cursor-not-allowed opacity-50', + onBlur: handleCloseUnsupportedTooltip, + onFocus: handleOpenUnsupportedTooltip, + onMouseEnter: handleOpenUnsupportedTooltip, + onMouseLeave: handleCloseUnsupportedTooltip, + }; + + return ( + <> + + {env.WAITING_ROOM_ALLOW_ADVANCED_SETTINGS && ( + + + {t('advancedSettings.title')} + + )} + + + + {t('backgroundEffects.title')} + + + + {t('waitingRoom.precallNetworkTest.title')} + + + + + {unsupportedFeatureTooltipTitle} + + + + ); +}; + +export default MenuMoreOptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/index.tsx new file mode 100644 index 00000000..aae4ca14 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MenuMoreOptions/index.tsx @@ -0,0 +1,3 @@ +import MenuMoreOptions from './MenuMoreOptions'; + +export default MenuMoreOptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx new file mode 100644 index 00000000..5dca2262 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.spec.tsx @@ -0,0 +1,128 @@ +import { render as renderBase, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReactElement } from 'react'; +import { makeTestProvider, providers, type ProviderOptions } from '@test/providers'; +import MicButton from './MicButton'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import composeProviders from '@web/helpers/composeProviders'; +import { env } from '../../../env'; +import { + setupWindowNavigatorMock, + makeMediaStreamMock, + makeMediaDeviceInfos, +} from '@web-test/fixtures'; +import { mediaDevices$ } from '@core/stores'; + +const mockDevices = makeMediaDeviceInfos(); + +describe('MicButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + + env.partialUpdate({ + ALLOW_MICROPHONE_CONTROL: true, + }); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(mockDevices), + getUserMedia: Promise.resolve(makeMediaStreamMock({})), + }, + }); + + // Initialize the mediaDevices$ store with mock devices to prevent useSyncPublisherDevices from disabling audio + mediaDevices$.setState((state) => ({ + ...state, + mediaDeviceInfo: mockDevices, + })); + + const { permissions } = globalThis.navigator; + + vi.spyOn(permissions, 'query').mockResolvedValue({ state: 'granted' } as PermissionStatus); + }); + + it('renders the mic on icon when audio is enabled', async () => { + render(, { + previewPublisherContext: { + __onCreated: (context) => { + context.isAudioEnabled = true; + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-microphone-line')).toBeInTheDocument(); + }); + }); + + it('renders the mic off icon when audio is disabled', async () => { + render(, { + previewPublisherContext: { + __onCreated: (context) => { + context.isAudioEnabled = false; + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('vivid-icon-mic-mute-line')).toBeInTheDocument(); + }); + }); + + it('calls toggleAudio when clicked', async () => { + const toggleAudioMock = vi.fn(); + render(, { + previewPublisherContext: { + __onCreated: (context) => { + context.isAudioEnabled = true; + const originalToggle = context.toggleAudio.bind(context); + context.toggleAudio = () => { + toggleAudioMock(); + return originalToggle(); + }; + }, + }, + }); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(toggleAudioMock).toHaveBeenCalled(); + }); + }); + + it('is not rendered when allowMicrophoneControl is false', async () => { + env.partialUpdate({ + ALLOW_MICROPHONE_CONTROL: false, + }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('vivid-icon-microphone-line')).not.toBeInTheDocument(); + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; +}; + +function render(ui: ReactElement, { userContext, previewPublisherContext }: RenderOptions = {}) { + const { wrapper: MainWrapper, ...context } = makeTestProvider( + [providers.user, providers.previewPublisher], + { + userContext, + previewPublisherContext, + } + ); + + const wrapper = composeProviders(SuspenseBoundary, MainWrapper); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx new file mode 100644 index 00000000..047309d4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/MicButton.tsx @@ -0,0 +1,84 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import usePreviewPublisherContext from '@hooks/usePreviewPublisherContext'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import VividIcon from '@ui/VividIcon'; +import { VIDEO_CONTAINER_BUTTON_SIZE_WR } from '@utils/constants'; +import VideoContainerButton from '../VideoContainerButton'; +import { env } from '../../../env'; + +/** + * MicButton Component + * + * Toggles the user's microphone (published audio) and updates the icon accordingly. + * @returns {ReactElement | false} - The MicButton component. + */ +const MicButton = (): ReactElement | false => { + const { t } = useTranslation(); + const { isAudioEnabled, toggleAudio } = usePreviewPublisherContext(); + + const title = isAudioEnabled + ? t('devices.audio.microphone.state.off') + : t('devices.audio.microphone.state.on'); + + return ( + env.ALLOW_MICROPHONE_CONTROL && ( + + + + ) : ( + + ) + } + /> + + + ) + ); +}; + +export default MicButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/index.tsx new file mode 100644 index 00000000..3f9abfe3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/MicButton/index.tsx @@ -0,0 +1,3 @@ +import MicButton from './MicButton'; + +export default MicButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.spec.tsx new file mode 100644 index 00000000..4f5dcc38 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.spec.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import DialogActionsRow from './DialogActionsRow'; + +describe('DialogActionsRow', () => { + it('renders close and primary buttons with provided labels', () => { + render( + {}} + actionButtonText="Retry" + onActionClick={() => {}} + /> + ); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('calls handlers when buttons are clicked', async () => { + const onClose = vi.fn(); + const onActionClick = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Close' })); + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onActionClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.tsx new file mode 100644 index 00000000..22309d45 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/DialogActionsRow.tsx @@ -0,0 +1,64 @@ +import { ReactElement } from 'react'; +import classNames from 'classnames'; +import { Box, Button } from '@mui/material'; + +export type DialogActionsRowProps = { + closeButtonText: string; + onClose: () => void; + actionButtonText: string; + onActionClick: () => void; + textColor?: string; + primaryTextColor?: string; +}; + +const DialogActionsRow = function DialogActionsRow({ + closeButtonText, + onClose, + actionButtonText, + onActionClick, + textColor, + primaryTextColor, +}: DialogActionsRowProps): ReactElement { + const resolvedTextColor = textColor; + const resolvedPrimaryTextColor = primaryTextColor; + + return ( + + + + + ); +}; + +export default DialogActionsRow; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/index.ts new file mode 100644 index 00000000..5d116cd4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/DialogActionsRow/index.ts @@ -0,0 +1,2 @@ +export { default } from './DialogActionsRow'; +export type { DialogActionsRowProps } from './DialogActionsRow'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.spec.tsx new file mode 100644 index 00000000..6b958a0c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.spec.tsx @@ -0,0 +1,330 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach, afterAll, type Mock } from 'vitest'; +import type { QualityResults } from './hooks/useNetworkTest'; +import type CancelablePromise from 'easy-cancelable-promise/CancelablePromise'; +import PrecallNetworkTestDialog from './PrecallNetworkTestDialog'; + +type NetworkTestState = { + isTestingQuality: boolean; + connectivityResults: unknown; + qualityResults: QualityResults | null; + error: { name: string; message: string } | null; +}; + +type NetworkTestHook = { + state: NetworkTestState; + testQuality: Mock<[roomName: string], CancelablePromise>; + stopTest: Mock; + clearResults: Mock; +}; + +const mockRoomName = 'test-room'; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useParams: () => ({ roomIdentifier: mockRoomName }), + }; +}); + +const mockUseNetworkTest = vi.fn<[], NetworkTestHook>(); + +vi.mock('./hooks/useNetworkTest', () => ({ + default: () => mockUseNetworkTest(), +})); + +function createNetworkTestHook(overrides?: Partial): NetworkTestHook { + return { + state: { + isTestingQuality: false, + connectivityResults: null, + qualityResults: null, + error: null, + ...overrides, + }, + testQuality: vi.fn(() => ({ + onProgress: vi.fn().mockReturnThis(), + })) as unknown as Mock<[roomName: string], CancelablePromise>, + stopTest: vi.fn(), + clearResults: vi.fn(), + }; +} + +describe('PrecallNetworkTestDialog', () => { + let networkTestHook: NetworkTestHook; + let unhandledRejectionHandler: ((reason: unknown) => void) | null = null; + + beforeEach(() => { + // Ignore "Promise canceled" errors from CancelablePromise cleanup + unhandledRejectionHandler = (reason: unknown) => { + if (reason instanceof Error && reason.message === 'Promise canceled') { + return; + } + throw reason; + }; + process.on('unhandledRejection', unhandledRejectionHandler); + + networkTestHook = createNetworkTestHook(); + mockUseNetworkTest.mockReturnValue(networkTestHook); + }); + + afterAll(() => { + if (unhandledRejectionHandler) { + process.off('unhandledRejection', unhandledRejectionHandler); + } + }); + + describe('Dialog visibility', () => { + it('renders dialog when open', () => { + render( + + ); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/pre-call network test/i)).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render( + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes dialog when close button is clicked', async () => { + const setOpen = vi.fn(); + + render( + + ); + + const closeButton = screen.getByLabelText(/close/i); + await userEvent.click(closeButton); + + expect(networkTestHook.stopTest).toHaveBeenCalled(); + expect(networkTestHook.clearResults).toHaveBeenCalled(); + expect(setOpen).toHaveBeenCalledWith(false); + }); + }); + + describe('Testing state', () => { + it('shows loading spinner when test is running', () => { + networkTestHook = createNetworkTestHook({ isTestingQuality: true }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByText(/stop test/i)).toBeInTheDocument(); + }); + + it('stops test when stop button is clicked', async () => { + networkTestHook = createNetworkTestHook({ isTestingQuality: true }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + await userEvent.click(screen.getByText(/stop test/i)); + + expect(networkTestHook.stopTest).toHaveBeenCalled(); + expect(networkTestHook.clearResults).toHaveBeenCalled(); + }); + + it('shows stopped state after user stops test', async () => { + networkTestHook = createNetworkTestHook({ isTestingQuality: true }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + const { rerender } = render( + + ); + + await userEvent.click(screen.getByText(/stop test/i)); + + networkTestHook = createNetworkTestHook({ isTestingQuality: false }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + rerender( + + ); + + expect(screen.getByText(/test stopped/i)).toBeInTheDocument(); + expect(screen.getByText(/retry test/i)).toBeInTheDocument(); + }); + }); + + describe('Results state', () => { + it('displays successful audio and video quality results', () => { + networkTestHook = createNetworkTestHook({ + qualityResults: { + audio: { mos: 4.2 }, + video: { mos: 3.75 }, + }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(screen.getByText(/audio/i)).toBeInTheDocument(); + expect(screen.getByText(/video/i)).toBeInTheDocument(); + expect(screen.getByText('4.20/5')).toBeInTheDocument(); + expect(screen.getByText('3.75/5')).toBeInTheDocument(); + expect(screen.getByTitle(/audio is supported/i)).toBeInTheDocument(); + expect(screen.getByTitle(/video is supported/i)).toBeInTheDocument(); + }); + + it('displays poor quality indicators for low scores', () => { + networkTestHook = createNetworkTestHook({ + qualityResults: { + audio: { mos: 2.5 }, + video: { mos: 1.8 }, + }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(screen.getByText('2.50/5')).toBeInTheDocument(); + expect(screen.getByText('1.80/5')).toBeInTheDocument(); + expect(screen.getByTitle(/audio is not supported/i)).toBeInTheDocument(); + expect(screen.getByTitle(/video is not supported/i)).toBeInTheDocument(); + }); + + it('displays error state when test fails', () => { + networkTestHook = createNetworkTestHook({ + error: { + name: 'NetworkError', + message: 'Connection failed', + }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(screen.getByText(/test failed/i)).toBeInTheDocument(); + expect(screen.getByText('Connection failed')).toBeInTheDocument(); + expect(screen.getByText(/retry test/i)).toBeInTheDocument(); + }); + }); + + describe('Retry functionality', () => { + it('clears results and starts new test when retry is clicked', async () => { + networkTestHook = createNetworkTestHook({ + qualityResults: { + audio: { mos: 4.2 }, + video: { mos: 3.75 }, + }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + await userEvent.click(screen.getByText(/retry test/i)); + + expect(networkTestHook.clearResults).toHaveBeenCalled(); + expect(networkTestHook.testQuality).toHaveBeenCalledWith(mockRoomName); + }); + + it('retries test from stopped state', async () => { + networkTestHook = createNetworkTestHook({ isTestingQuality: true }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + const { rerender } = render( + + ); + + await userEvent.click(screen.getByText(/stop test/i)); + + networkTestHook = createNetworkTestHook({ isTestingQuality: false }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + rerender( + + ); + + await userEvent.click(screen.getByText(/retry test/i)); + + expect(networkTestHook.clearResults).toHaveBeenCalled(); + expect(networkTestHook.testQuality).toHaveBeenCalledWith(mockRoomName); + }); + + it('retries test from error state', async () => { + networkTestHook = createNetworkTestHook({ + error: { name: 'NetworkError', message: 'Failed' }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + await userEvent.click(screen.getByText(/retry test/i)); + + expect(networkTestHook.clearResults).toHaveBeenCalled(); + expect(networkTestHook.testQuality).toHaveBeenCalledWith(mockRoomName); + }); + }); + + describe('Auto-start behavior', () => { + it('automatically starts test when dialog opens', () => { + const { rerender } = render( + + ); + + expect(networkTestHook.testQuality).not.toHaveBeenCalled(); + + rerender( + + ); + + expect(networkTestHook.testQuality).toHaveBeenCalledWith(mockRoomName); + }); + + it('does not auto-start if test is already running', () => { + networkTestHook = createNetworkTestHook({ isTestingQuality: true }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(networkTestHook.testQuality).not.toHaveBeenCalled(); + }); + + it('does not auto-start if results already exist', () => { + networkTestHook = createNetworkTestHook({ + qualityResults: { + audio: { mos: 4.2 }, + video: { mos: 3.75 }, + }, + }); + mockUseNetworkTest.mockReturnValue(networkTestHook); + + render( + + ); + + expect(networkTestHook.testQuality).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.tsx new file mode 100644 index 00000000..a9bd00ff --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestDialog.tsx @@ -0,0 +1,289 @@ +import { ReactElement, useEffect, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import IconButton from '@mui/material/IconButton'; +import DialogContent from '@mui/material/DialogContent'; +import VividIcon from '@ui/VividIcon'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import useSessionKeyParam from '@hooks/useSessionKeyParam'; +import useDecodedSessionKey from '@hooks/useDecodedSessionKey'; +import tryCatch from '@common/execution/tryCatch'; +import PrecallNetworkTestQualityRow from './PrecallNetworkTestQualityRow'; +import DialogActionsRow from './DialogActionsRow'; +import useNetworkTest from './hooks/useNetworkTest'; + +export type PrecallNetworkTestDialogProps = { + isPrecallNetworkTestOpen: boolean; + setIsPrecallNetworkTestOpen: (open: boolean) => void; +}; + +/** + * PrecallNetworkTestDialog Component + * + * This component renders a dialog for pre-call network testing in the waiting room. + * @param {PrecallNetworkTestDialogProps} props - The props for the component. + * @property {boolean} isPrecallNetworkTestOpen - Whether the dialog is open. + * @property {Function} setIsPrecallNetworkTestOpen - Function to set the open state of the dialog. + * @returns {ReactElement} The pre-call network test dialog component. + */ +const PrecallNetworkTestDialog = ({ + isPrecallNetworkTestOpen, + setIsPrecallNetworkTestOpen, +}: PrecallNetworkTestDialogProps): ReactElement => { + const { t } = useTranslation(); + const { sessionKey, sessionKeyStatus } = useSessionKeyParam(); + const { roomName } = useDecodedSessionKey({ sessionKey, sessionKeyStatus }); + + const { state, testQuality, stopTest, clearResults } = useNetworkTest(); + + const [hasUserStoppedTest, setHasUserStoppedTest] = useState(false); + + const handleClose = useCallback(() => { + stopTest(); + clearResults(); + setHasUserStoppedTest(false); + setIsPrecallNetworkTestOpen(false); + }, [stopTest, clearResults, setIsPrecallNetworkTestOpen]); + + const handleStopTest = useCallback(() => { + stopTest(); + clearResults(); + setHasUserStoppedTest(true); + }, [stopTest, clearResults]); + + const handleStartTest = useCallback(async () => { + const { error } = await tryCatch(() => + testQuality(roomName).onProgress((_, stats) => { + console.log('Quality test stats update:', stats); + }) + ); + + if (error) { + console.error('Network test failed:', error); + } + }, [testQuality, roomName]); + + const handleRetry = useCallback(() => { + setHasUserStoppedTest(false); + clearResults(); + void handleStartTest(); + }, [clearResults, handleStartTest]); + + const getRoundedQualityScore = function getRoundedQualityScore( + score: number | undefined + ): number | null { + if (typeof score !== 'number') return null; + return Math.round(score * 100) / 100; + }; + const audioScore = getRoundedQualityScore(state.qualityResults?.audio?.mos); + const videoScore = getRoundedQualityScore(state.qualityResults?.video?.mos); + + const audioSupportTitle = (() => { + if (audioScore === null) return undefined; + + const translationKey = + audioScore >= 3 + ? 'waitingRoom.precallNetworkTest.audioSupported' + : 'waitingRoom.precallNetworkTest.audioNotSupported'; + + return t(translationKey); + })(); + + const videoSupportTitle = (() => { + if (videoScore === null) return undefined; + + const translationKey = + videoScore >= 3 + ? 'waitingRoom.precallNetworkTest.videoSupported' + : 'waitingRoom.precallNetworkTest.videoNotSupported'; + + return t(translationKey); + })(); + + const shouldShowTestingState = state.isTestingQuality && !hasUserStoppedTest; + const shouldShowResultsState = + !state.isTestingQuality && Boolean(state.qualityResults || state.error); + + const shouldShowStoppedState = hasUserStoppedTest && !state.qualityResults && !state.error; + + useEffect(() => { + if (!isPrecallNetworkTestOpen) return; + + if (hasUserStoppedTest) return; + + const hasExistingResultsOrError = Boolean(state.qualityResults || state.error); + if (state.isTestingQuality || hasExistingResultsOrError) return; + + void handleStartTest(); + }, [ + isPrecallNetworkTestOpen, + hasUserStoppedTest, + state.isTestingQuality, + state.qualityResults, + state.error, + handleStartTest, + ]); + + return ( + + + + {t('waitingRoom.precallNetworkTest.title')} + + + + + + + + + {t('waitingRoom.precallNetworkTest.subtitle')} + + + {shouldShowTestingState && ( + + + + + + + )} + + {shouldShowStoppedState && ( + + + + {t('waitingRoom.precallNetworkTest.stoppedTitle')} + + + {t('waitingRoom.precallNetworkTest.stoppedMessage')} + + + + + + )} + + {shouldShowResultsState && ( + + {state.error ? ( + + + + {t('waitingRoom.precallNetworkTest.error')} + + + {state.error.message} + + + ) : ( + + + + + )} + + + + )} + + + + ); +}; + +export default PrecallNetworkTestDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.spec.tsx new file mode 100644 index 00000000..d33c19dd --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.spec.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import PrecallNetworkTestQualityRow from './PrecallNetworkTestQualityRow'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('PrecallNetworkTestQualityRow', () => { + it('renders the provided label and quality label text', () => { + render( + + ); + + expect(screen.getByText('Audio')).toBeInTheDocument(); + expect(screen.getByText('waitingRoom.precallNetworkTest.qualityLabel')).toBeInTheDocument(); + }); + + it('formats and displays the score with two decimals and /5', () => { + render(); + + expect(screen.getByText('3.14/5')).toBeInTheDocument(); + }); + + it('shows a check icon when score is >= 3', () => { + render(); + + expect(screen.getByTestId('vivid-icon-check-circle-line')).toBeInTheDocument(); + expect(screen.queryByTestId('vivid-icon-close-circle-line')).not.toBeInTheDocument(); + }); + + it('shows a close icon when score is below 3', () => { + render(); + + expect(screen.getByTestId('vivid-icon-close-circle-line')).toBeInTheDocument(); + expect(screen.queryByTestId('vivid-icon-check-circle-line')).not.toBeInTheDocument(); + expect(screen.getByText('2.99/5')).toBeInTheDocument(); + }); + + it('applies the supportTitle as a title attribute on the row', () => { + render(); + + expect(screen.getByTitle('Audio supported')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.tsx new file mode 100644 index 00000000..656e2489 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/PrecallNetworkTestQualityRow.tsx @@ -0,0 +1,80 @@ +import { ReactElement, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import VividIcon from '@ui/VividIcon'; +import { useTranslation } from 'react-i18next'; + +export type PrecallNetworkTestQualityRowProps = { + label: string; + score: number | null; + supportTitle?: string; +}; + +const PrecallNetworkTestQualityRow = ({ + label, + score, + supportTitle, +}: PrecallNetworkTestQualityRowProps): ReactElement => { + const { t } = useTranslation(); + + const isSupported = useMemo(() => score !== null && score >= 3, [score]); + + const formattedScore = useMemo(() => { + if (score === null) return '—'; + return `${score.toFixed(2)}/5`; + }, [score]); + + return ( + + + {label} + + + {isSupported ? ( + + ) : ( + + )} + + + {t('waitingRoom.precallNetworkTest.qualityLabel')} + + + {formattedScore} + + + ); +}; + +export default PrecallNetworkTestQualityRow; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/index.ts new file mode 100644 index 00000000..ca128f21 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/PrecallNetworkTestQualityRow/index.ts @@ -0,0 +1 @@ +export { default } from './PrecallNetworkTestQualityRow'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.spec.tsx new file mode 100644 index 00000000..5ae7136c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.spec.tsx @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import NetworkTestConstructor from '@vonage/video-client-network-test'; +import { makeTestProvider, providers } from '@test/providers'; +import type { VideoClient } from '@core/services'; + +const mockNetworkTest = vi.hoisted(() => ({ + testQuality: vi.fn(), + stop: vi.fn(), +})); + +vi.mock('@vonage/video-client-network-test', () => { + const MockNetworkTest = vi.fn(() => mockNetworkTest); + + return { + __esModule: true, + default: MockNetworkTest, + ErrorNames: { + UNSUPPORTED_BROWSER: 'UNSUPPORTED_BROWSER', + MISSING_DEVICE_LABELS: 'MISSING_DEVICE_LABELS', + }, + }; +}); + +vi.mock('@vonage/client-sdk-video', () => ({ + __esModule: true, + default: {}, +})); + +vi.mock('@api/fetchCredentials'); + +const mockCreateSessionAndJoinMutate = vi.fn(); + +const mockVideoClient = { + createSessionAndJoin: (...args: unknown[]) => mockCreateSessionAndJoinMutate(...args) as unknown, +} as unknown as VideoClient; + +const mockT = vi.fn((key: string) => key); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: mockT, + }), +})); + +import useNetworkTest, { type QualityResults } from './useNetworkTest'; +import wait from '@common/execution/wait/wait'; +import CancelablePromise from 'easy-cancelable-promise'; + +const { wrapper } = makeTestProvider([providers.runtime], { + runtimeContext: { videoClient: mockVideoClient }, +}); + +function render(hook: () => T) { + return renderHook(hook, { wrapper }); +} + +describe('useNetworkTest', () => { + const mockCredentials = { + applicationId: 'test-api-key', + sessionId: 'test-session-id', + token: 'test-token', + }; + + const mockQualityResults: QualityResults = { + video: { + supported: true, + recommendedFrameRate: 30, + recommendedResolution: '1280x720', + bitrate: 1000, + frameRate: 30, + packetLossRatio: 0.01, + mos: 4.2, + }, + audio: { + supported: true, + bitrate: 128, + packetLossRatio: 0.005, + mos: 4.5, + }, + }; + + beforeEach(() => { + mockNetworkTest.testQuality.mockReset(); + mockNetworkTest.stop.mockReset(); + mockCreateSessionAndJoinMutate.mockResolvedValue(mockCredentials); + mockNetworkTest.testQuality.mockResolvedValue(mockQualityResults); + }); + + it('returns initial state correctly', () => { + const { result } = render(() => useNetworkTest()); + + expect(result.current).not.toBeNull(); + expect(result.current).toBeDefined(); + expect(result.current.state).toEqual({ + isTestingQuality: false, + connectivityResults: null, + qualityResults: null, + error: null, + }); + }); + + it('testQuality fetches credentials and creates NetworkTest instance', async () => { + const { result } = render(() => useNetworkTest()); + + await act(async () => { + await result.current.testQuality('test-room'); + }); + + expect(mockCreateSessionAndJoinMutate).toHaveBeenCalled(); + expect(NetworkTestConstructor).toHaveBeenCalledWith( + expect.anything(), + { + applicationId: 'test-api-key', + sessionId: 'test-session-id', + token: 'test-token', + }, + {} + ); + }); + + it('testQuality returns quality results', async () => { + const { result } = render(() => useNetworkTest()); + + let returnedResults: QualityResults | undefined; + await act(async () => { + returnedResults = await result.current.testQuality('test-room'); + }); + + expect(returnedResults).toEqual(mockQualityResults); + }); + + it('testQuality handles errors correctly', async () => { + const { result } = render(() => useNetworkTest()); + const mockError = new Error('Network test failed'); + mockError.name = 'NetworkError'; + mockNetworkTest.testQuality.mockRejectedValue(mockError); + + await act(async () => { + await expect(result.current.testQuality('test-room')).rejects.toThrow('Network test failed'); + }); + + expect(result.current.state.isTestingQuality).toBe(false); + expect(result.current.state.error).toEqual({ + message: 'Network test failed', + name: 'NetworkError', + }); + }); + + /** + * TODO: We need to find a way of tell vitest that the unhandled reject is actually expected here. + */ + it('stopTest stops the network test instance', async () => { + const { result } = render(() => useNetworkTest()); + let promise: CancelablePromise; + + vi.spyOn(mockNetworkTest, 'testQuality').mockImplementation(() => wait(10)); + + act(() => { + promise = result.current.testQuality('test-room'); + }); + + await waitFor(() => { + expect(result.current.state.isTestingQuality).toBe(true); + }); + + act(() => { + result.current.stopTest(); + }); + + void promise!.catch(() => {}); + + expect(promise!.status).toBe('canceled'); + expect(result.current.state.isTestingQuality).toBe(false); + }); + + it('stopTest does nothing when no test is running', () => { + const { result } = render(() => useNetworkTest()); + + act(() => { + result.current.stopTest(); + }); + + expect(mockNetworkTest.stop).not.toHaveBeenCalled(); + expect(result.current.state.isTestingQuality).toBe(false); + }); + + it('stopTest prevents late quality results from updating state', async () => { + const { result } = render(() => useNetworkTest()); + + let promise: CancelablePromise | null = null; + + let resolveQualityTest: ((value: QualityResults) => void) | null = null; + + mockNetworkTest.testQuality.mockImplementation(() => { + return new Promise((resolve) => { + resolveQualityTest = resolve; + }); + }); + + promise = result.current.testQuality('test-room'); + + await waitFor(() => { + expect(result.current.state.isTestingQuality).toBe(true); + }); + + act(() => { + result.current.stopTest(); + }); + + void promise?.catch(() => {}); + + await waitFor(() => { + expect(result.current.state.isTestingQuality).toBe(false); + }); + + act(() => { + resolveQualityTest?.(mockQualityResults); + }); + + await waitFor(() => { + expect(result.current.state.qualityResults).toBeNull(); + }); + }); + + it('clearResults resets state to initial values', async () => { + const { result } = render(() => useNetworkTest()); + + await act(async () => { + await result.current.testQuality('test-room'); + }); + + expect(result.current.state.qualityResults).not.toBeNull(); + + act(() => { + result.current.clearResults(); + }); + + expect(result.current.state).toEqual({ + isTestingQuality: false, + connectivityResults: null, + qualityResults: null, + error: null, + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.tsx new file mode 100644 index 00000000..65cf6270 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/hooks/useNetworkTest.tsx @@ -0,0 +1,235 @@ +import { useState, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import NetworkTest, { + type ErrorNames, + type QualityTestResults, +} from '@vonage/video-client-network-test'; +import OT from '@vonage/client-sdk-video'; +import CancelablePromise from 'easy-cancelable-promise/CancelablePromise'; +import attempt from '@common/execution/attempt'; +import useMountEffect from '@web/hooks/useMountEffect'; +import { runtime$ } from '@core/stores'; + +export type QualityResults = { + video?: { + supported?: boolean; + recommendedFrameRate?: number | null; + recommendedResolution?: string | null; + reason?: string; + qualityLimitationReason?: string | null; + bitrate?: number; + frameRate?: number; + packetLossRatio?: number; + mos?: number; + }; + audio?: { + supported?: boolean; + reason?: string; + bitrate?: number; + packetLossRatio?: number; + mos?: number; + }; +}; + +export type QualityUpdateStats = { + audio?: { + timestamp?: number; + bytesSent?: number; + bytesReceived?: number; + packetsReceived?: number; + packetsLost?: number; + }; + video?: { + timestamp?: number; + bytesReceived?: number; + bytesSent?: number; + frameRate?: number; + packetsReceived?: number; + packetsLost?: number; + }; + timestamp?: number; + phase?: string; +}; + +type ConnectivityResults = { + success: boolean; + failedTests?: Array<{ + type: string; + error: { + message: string; + name: string; + }; + }>; +}; + +type NetworkTestOptions = { + audioOnly?: boolean; + timeout?: number; + audioSource?: string; + videoSource?: string; +}; + +type NetworkTestError = { + message: string; + name: string; +}; + +type NetworkTestState = { + isTestingQuality: boolean; + connectivityResults: ConnectivityResults | null; + qualityResults: QualityResults | null; + error: NetworkTestError | null; +}; + +/** + * Hook for running Vonage Video API network connectivity and quality tests. + * Provides methods to test device access, connectivity to Vonage Video API servers, + * and audio/video quality with MOS estimates. + * @returns {NetworkTestHookType} Object containing test state and methods + */ +const useNetworkTest = () => { + const videoClient = runtime$.useVideoClient(); + const { t } = useTranslation(); + + const testPromiseRef = useRef | null>(null); + + const [state, setState] = useState({ + isTestingQuality: false, + connectivityResults: null, + qualityResults: null, + error: null, + }); + + // /** + // * The @vonage/video-client-network-test library `stop()` does not guarantee that an in-flight + // * `testQuality()` promise will never resolve afterwards (the library may wait for enough stats + // * before completing). To ensure the UI truly stops, we invalidate the active run so any late + // * callbacks/results are ignored and cannot update React state. + // */ + // const activeQualityTestRunIdentifierRef = useRef(0); + + // const isActiveQualityTestRun = useCallback((runIdentifier: number) => { + // return activeQualityTestRunIdentifierRef.current === runIdentifier; + // }, []); + + const clearResults = useCallback(() => { + setState({ + isTestingQuality: false, + connectivityResults: null, + qualityResults: null, + error: null, + }); + }, []); + + const stopTest = useCallback(() => { + void testPromiseRef.current?.cancel(); + }, []); + + const testQuality = useCallback( + (roomName: string, options: NetworkTestOptions = {}): CancelablePromise => { + const isPending = testPromiseRef.current?.status === 'pending'; + if (isPending) return testPromiseRef.current!; + + setState((prev) => ({ + ...prev, + isTestingQuality: true, + error: null, + })); + + return (testPromiseRef.current = new CancelablePromise( + async (resolve, reject, { isCanceled, reportProgress, onCancel }) => { + try { + const { applicationId, sessionId, token } = await videoClient.createSessionAndJoin(); + + if (isCanceled()) return; + + const networkTest = new NetworkTest( + OT, + { + applicationId, + sessionId, + token, + }, + options + ); + + onCancel(() => { + void attempt(() => { + networkTest.stop(); + }); + + setState((prev) => ({ + ...prev, + isTestingQuality: false, + })); + }); + + // timeout after 30s + + const qualityResults = await new Promise((res, rej) => { + const timeout = setTimeout( + () => rej(new Error('Network test timed out')), + options.timeout || 30000 + ); + + networkTest + .testQuality((qualityUpdateStats) => { + if (isCanceled()) return; + + reportProgress(-1, qualityUpdateStats); + }) + .then(res) + .catch(rej) + .finally(() => { + clearTimeout(timeout); + }); + }); + + if (isCanceled()) return; + + setState((prev) => ({ + ...prev, + qualityResults, + isTestingQuality: false, + })); + + return resolve(qualityResults); + } catch (error) { + if (isCanceled()) return; + + const networkError: NetworkTestError = { + message: + error instanceof Error ? error.message : t('waitingRoom.precallNetworkTest.error'), + name: (error as Error & { name?: string })?.name || 'NetworkTestError', + }; + + setState((prev) => ({ + ...prev, + error: networkError, + isTestingQuality: false, + })); + + reject(networkError); + } + } + )); + }, + [t, videoClient] + ); + + useMountEffect(() => { + return () => { + stopTest(); + }; + }); + + return { + state, + testQuality, + stopTest, + clearResults, + }; +}; + +export { ErrorNames }; +export default useNetworkTest; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/index.tsx new file mode 100644 index 00000000..9344b73c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PrecallNetworkTestDialog/index.tsx @@ -0,0 +1,3 @@ +import PrecallNetworkTestDialog from './PrecallNetworkTestDialog'; + +export default PrecallNetworkTestDialog; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.spec.tsx new file mode 100644 index 00000000..5b5a61f5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.spec.tsx @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { ReactNode } from 'react'; +import PreviewAvatar from './PreviewAvatar'; + +const TestComponent = ({ children }: { children: ReactNode }) => { + return
        {children}
        ; +}; + +describe('PreviewAvatar', () => { + afterEach(() => { + cleanup(); + }); + + it('should render initials when provided', () => { + render( + + ); + + expect(screen.getByText(/AZ/)).toBeVisible(); + }); + + it('should render avatar when no initials are provided', () => { + const { container } = render( + + ); + + const avatar = container.querySelector('[data-testid="PersonIcon"]'); + expect(avatar).toBeVisible(); + }); + + describe('should not be rendered', () => { + it('when loading', () => { + render( + + + + ); + + expect(screen.getByTestId('TestComponent')).toBeEmptyDOMElement(); + }); + + it('when publishing video is enabled', () => { + render( + + + + ); + + expect(screen.getByTestId('TestComponent')).toBeEmptyDOMElement(); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx new file mode 100644 index 00000000..a0c0d233 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/PreviewAvatar.tsx @@ -0,0 +1,66 @@ +import { ReactElement } from 'react'; +import Avatar from '@mui/material/Avatar'; +import useWindowWidth from '../../../hooks/useWindowWidth'; +import AvatarInitials from '../../AvatarInitials'; +import useIsSmallViewport from '../../../hooks/useIsSmallViewport'; + +export type PreviewAvatarProps = { + username: string; + initials: string; + isVideoEnabled: boolean; + isVideoLoading: boolean; +}; + +/** + * PreviewAvatar Component + * + * Displays the user's initials if initials are available, an empty avatar if initials are unavailable, or nothing if the user is publishing video or the publisher is initializing. + * @param {PreviewAvatarProps} props - The props for the component. + * @property {string} username - The user's username. + * @property {string} initials - The user's initials. + * @property {boolean} isVideoEnabled - Whether the user is publishing video. + * @property {boolean} isVideoLoading - Whether the preview publisher is initialized. + * @returns {ReactElement | null} - The PreviewAvatar component or `null`. + */ +const PreviewAvatar = ({ + initials, + username, + isVideoEnabled, + isVideoLoading, +}: PreviewAvatarProps): ReactElement | null => { + const smallDisplayDimensions = useWindowWidth() * 0.46; + const isSmallViewport = useIsSmallViewport(); + const height = isSmallViewport ? smallDisplayDimensions : 328; + const width = isSmallViewport ? smallDisplayDimensions : 584; + if (isVideoEnabled || isVideoLoading) { + return null; + } + + return initials ? ( + + ) : ( + + ); +}; + +export default PreviewAvatar; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/index.tsx new file mode 100644 index 00000000..24b61678 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/PreviewAvatar/index.tsx @@ -0,0 +1,3 @@ +import PreviewAvatar from './PreviewAvatar'; + +export default PreviewAvatar; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.spec.tsx new file mode 100644 index 00000000..5fa508ba --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.spec.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import UsernameInputSkeleton from './UserNameInput.skeleton'; + +describe('UsernameInputSkeleton', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx new file mode 100644 index 00000000..fa4ebb93 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.skeleton.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { Card, CardProps } from '@ui'; +import { Skeleton } from '@mui/material'; +import Separator from '@components/Separator'; +import { twMerge } from 'tailwind-merge'; + +type UsernameInputSkeletonProps = Omit, 'sx'>; + +const UsernameInputSkeleton: FC = ({ className, ...props }) => { + return ( + +
        + {/* title */} + + + {/* name input */} + + + + + {/* subtitle */} + + + {/* room name */} + + + +
        +
        + ); +}; + +export default UsernameInputSkeleton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.tsx new file mode 100644 index 00000000..5aed13f7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/UserNameInput.tsx @@ -0,0 +1,140 @@ +import React, { Dispatch, MouseEvent, ReactElement, SetStateAction, useState, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Card from '@ui/Card'; +import useUserContext from '@hooks/useUserContext'; +import { UserType } from '@Context/user'; +import isValidUserName from '@utils/isValidUserName'; +import { setStorageItem, STORAGE_KEYS } from '@utils/storage'; +import Separator from '@components/Separator'; +import { CardProps } from '@mui/material'; +import { twMerge } from 'tailwind-merge'; +import { isValidRoomName } from '@common/assertions'; + +export type UserNameInputProps = Omit & { + username: string; + roomName: string; + setUsername: Dispatch>; +}; + +/** + * UsernameInput Component + * + * Handles setting the username and navigating to the meeting room. + * @param {UserNameInputProps} props - The props for the component. + * @property {string} username - The user's name + * @property {Dispatch>} setUsername - Function to update the user's username. + * @returns {ReactElement} The UsernameInput component. + */ +const UsernameInput = ({ + roomName, + username, + setUsername, + className, + ...cardProps +}: UserNameInputProps): ReactElement => { + const { t } = useTranslation(); + const { setUser } = useUserContext(); + const navigate = useNavigate(); + + const [isUserNameInvalid, setIsUserNameInvalid] = useState(false); + const inputRef = useRef(null); + + const onChangeParticipantName = (e: React.ChangeEvent) => { + const inputUserName = e.target.value; + setIsUserNameInvalid(false); + setUsername(inputUserName); + }; + + const handleInputFocus = () => { + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + + const validateForm = () => { + if (!isValidUserName(username)) { + setIsUserNameInvalid(true); + return false; + } + return true; + }; + + const handleJoinClick = (event: MouseEvent) => { + event.preventDefault(); + if (validateForm() && roomName) { + if (!isValidRoomName(roomName)) { + return; + } + setUser((prevUser: UserType) => ({ + ...prevUser, + defaultSettings: { + ...prevUser.defaultSettings, + name: username, + }, + })); + setStorageItem(STORAGE_KEYS.USERNAME, username); + // This takes the user to the meeting room and allows them to enter it + // Otherwise if they entered the room directly, they are going to be redirected back to the waiting room + // Setting hasAccess is required so that we are not redirected back to the waiting room + navigate(`/room/${roomName}`, { + state: { + hasAccess: true, + }, + }); + } + }; + + return ( + + + {t('waitingRoom.user.input.title')} + + + + + + + + + + {t('waitingRoom.title')} + + + + {roomName} + + + + + ); +}; + +export default UsernameInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/index.tsx new file mode 100644 index 00000000..178e6e5d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/UserNameInput/index.tsx @@ -0,0 +1,3 @@ +import UserNameInput from './UserNameInput'; + +export default UserNameInput; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx new file mode 100644 index 00000000..60aec190 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.spec.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import VideoContainerSkeleton from './VideoContainer.skeleton'; + +describe('VideoContainerSkeleton', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx new file mode 100644 index 00000000..6e40c36c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.skeleton.tsx @@ -0,0 +1,43 @@ +import { ComponentProps, FC } from 'react'; +import classNames from 'classnames'; +import PreviewAvatar from '../PreviewAvatar'; +import VignetteEffect from '../VignetteEffect'; +import { twMerge } from 'tailwind-merge'; + +type VideoContainerSkeletonProps = ComponentProps<'div'>; + +const VideoContainerSkeleton: FC = ({ className, ...props }) => { + return ( +
        + + + +
        + ); +}; + +export default VideoContainerSkeleton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx new file mode 100644 index 00000000..801680a9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/VideoContainer.tsx @@ -0,0 +1,136 @@ +import { useRef, useEffect, ReactElement } from 'react'; +import { VIDEO_CONTAINER_HEIGHT_WR } from '@utils/constants'; +import MicButton from '../MicButton'; +import CameraButton from '../CameraButton'; +import VideoLoading from '../VideoLoading'; +import useUserContext from '../../../hooks/useUserContext'; +import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; +import getInitials from '../../../utils/getInitials'; +import PreviewAvatar from '../PreviewAvatar'; +import VoiceIndicatorIcon from '../../MeetingRoom/VoiceIndicator/VoiceIndicator'; +import VignetteEffect from '../VignetteEffect'; +import BackgroundEffectsDialog from '../BackgroundEffects/BackgroundEffectsDialog'; +import BackgroundEffectsButton from '../BackgroundEffects/BackgroundEffectsButton'; +import AdvancedSettingsDialog from '@components/AdvancedSettings/Dialog'; +import advancedSettings$ from '@Context/AdvancedSettings'; +import backgroundEffectsDialog$ from '@Context/BackgroundEffectsDialog'; +import PrecallNetworkTestDialog from '../PrecallNetworkTestDialog'; +import precallNetworkTestDialog$ from '@Context/PrecallNetworkTestDialog'; +import classNames from 'classnames'; +import { env } from '../../../env'; +import VideoStatsOverlay from '../VideoStatsOverlay'; + +export type VideoContainerProps = { + username: string; +}; + +/** + * VideoContainer Component + * + * Loads and displays the preview publisher, a representation of what participants would see in the meeting room. + * Overlaid onto the preview publisher are the audio input toggle button, video input toggle button, and the background replacement button (if supported). + * @param {VideoContainerProps} props - The props for the component. + * @property {string} username - The user's username. + * @returns {ReactElement} - The VideoContainer component. + */ +const VideoContainer = ({ username }: VideoContainerProps): ReactElement => { + const containerRef = useRef(null); + const isAdvancedSettingsOpen = advancedSettings$.use.select((state) => state.isOpen); + const [{ isOpen: isBackgroundEffectsOpen }, { open, close }] = backgroundEffectsDialog$.use(); + const [{ isOpen: isPrecallNetworkTestOpen }, { close: closePrecallTest }] = + precallNetworkTestDialog$.use(); + const { user } = useUserContext(); + const { publisherVideoElement, isVideoEnabled, isAudioEnabled, speechLevel, isVideoLoading } = + usePreviewPublisherContext(); + const initials = getInitials(username); + + useEffect(() => { + if (!publisherVideoElement) return; + + containerRef.current!.appendChild(publisherVideoElement); + }, [publisherVideoElement]); + + return ( +
        +
        + + + + {env.SHOW_VIDEO_STATS && isVideoEnabled && !isVideoLoading && ( +
        + +
        + )} + + {isVideoLoading && } + + + + {!isVideoLoading && ( +
        + {isAudioEnabled && ( +
        + +
        + )} +
        + + +
        +
        + + {isBackgroundEffectsOpen && ( + + )} + {isAdvancedSettingsOpen && } + {isPrecallNetworkTestOpen && ( + + )} +
        +
        + )} +
        + ); +}; + +export default VideoContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/index.tsx new file mode 100644 index 00000000..39f45827 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainer/index.tsx @@ -0,0 +1,3 @@ +import VideoContainer from './VideoContainer'; + +export default VideoContainer; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/VideoContainerButton.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/VideoContainerButton.tsx new file mode 100644 index 00000000..071bca61 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/VideoContainerButton.tsx @@ -0,0 +1,41 @@ +import IconButton from '@mui/material/IconButton'; +import type { IconButtonProps } from '@mui/material/IconButton'; +import type { SxProps } from '@mui/material'; +import { ForwardedRef, forwardRef, ReactElement } from 'react'; +import classNames from 'classnames'; +import { twMerge } from 'tailwind-merge'; + +export type VideoContainerButtonProps = IconButtonProps & { + onClick: () => void; + icon: ReactElement; + sx?: SxProps; +}; + +/** + * VideoContainerButton Component + * + * An overlay button for the preview publisher. + * @param {VideoContainerButtonProps} props - The props for the component. + * @property {Function} onClick - The on-click handler for the button. + * @property {ReactElement} icon - The Icon element for the button. + * @property {SxProps} sx - The style properties for the component. + * @returns {ReactElement} The VideoContainerButton component. + */ +const VideoContainerButton = forwardRef(function VideoContainerButton( + props: VideoContainerButtonProps, + ref: ForwardedRef +): ReactElement { + const { icon: Icon, className, ...rest } = props; + return ( + + {Icon} + + ); +}); + +export default VideoContainerButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/index.tsx new file mode 100644 index 00000000..207bae81 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoContainerButton/index.tsx @@ -0,0 +1,3 @@ +import VideoContainerButton from './VideoContainerButton'; + +export default VideoContainerButton; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx new file mode 100644 index 00000000..3a83e346 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.spec.tsx @@ -0,0 +1,15 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import VideoLoading from './VideoLoading'; + +describe('VideoLoading', () => { + afterEach(() => { + cleanup(); + }); + + it('should render the VideoLoading component', () => { + render(); + + expect(screen.getByTestId('VideoLoading')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.tsx new file mode 100644 index 00000000..b8bd1347 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/VideoLoading.tsx @@ -0,0 +1,35 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import { FC } from 'react'; +import Box from '@mui/material/Box'; +import type { BoxProps } from '@mui/material/Box'; +import { twMerge } from 'tailwind-merge'; + +type VideoLoadingProps = { + className?: string; +} & BoxProps; + +/** + * VideoLoading Component + * + * Displays a video loading component while the Preview Publisher is being initialized. + * @returns {ReactElement} - The VideoLoading component + */ +const VideoLoading: FC = ({ className, ...props }) => { + return ( + + + + ); +}; + +export default VideoLoading; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/index.tsx new file mode 100644 index 00000000..c46dcd21 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoLoading/index.tsx @@ -0,0 +1,3 @@ +import VideoLoading from './VideoLoading'; + +export default VideoLoading; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.spec.tsx new file mode 100644 index 00000000..33f808c3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.spec.tsx @@ -0,0 +1,109 @@ +import { render as renderBase, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactElement } from 'react'; +import type { Publisher } from '@vonage/client-sdk-video'; +import { makeTestProvider, providers } from '@test/providers'; +import composeProviders from '@web/helpers/composeProviders'; +import SuspenseBoundary from '@web/components/SuspenseBoundary/SuspenseBoundary'; +import { + setupWindowNavigatorMock, + makeMediaStreamMock, + makeMediaDeviceInfos, +} from '@web-test/fixtures'; +import VideoStatsOverlay from './VideoStatsOverlay'; + +const mockDevices = makeMediaDeviceInfos(); + +const mockPublisher = { + videoWidth: vi.fn().mockReturnValue(1280), + videoHeight: vi.fn().mockReturnValue(720), + getVideoSource: vi.fn().mockReturnValue({ + track: { + getSettings: vi.fn().mockReturnValue({ frameRate: 30 }), + }, + }), +} as unknown as Publisher; + +describe('VideoStatsOverlay', () => { + beforeEach(() => { + vi.clearAllMocks(); + (mockPublisher.videoWidth as ReturnType).mockReturnValue(1280); + (mockPublisher.videoHeight as ReturnType).mockReturnValue(720); + (mockPublisher.getVideoSource as ReturnType).mockReturnValue({ + track: { + getSettings: vi.fn().mockReturnValue({ frameRate: 30 }), + }, + }); + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve(mockDevices), + getUserMedia: Promise.resolve(makeMediaStreamMock({})), + }, + }); + + vi.spyOn(globalThis.navigator.permissions, 'query').mockResolvedValue({ + state: 'granted', + } as PermissionStatus); + }); + + it('should render the stats badge with resolution and framerate', async () => { + render(); + + expect(await screen.findByTestId('video-stats-overlay')).toBeInTheDocument(); + expect(screen.getByText('720p 30fps')).toBeInTheDocument(); + }); + + it('should render only resolution when framerate is not available', async () => { + (mockPublisher.getVideoSource as ReturnType).mockReturnValue({ + track: null, + }); + + render(); + + expect(await screen.findByText('720p')).toBeInTheDocument(); + }); + + it('should display "–" when no stats are available', async () => { + (mockPublisher.videoWidth as ReturnType).mockReturnValue(undefined); + (mockPublisher.videoHeight as ReturnType).mockReturnValue(undefined); + (mockPublisher.getVideoSource as ReturnType).mockReturnValue({ + track: null, + }); + + render(); + + expect(await screen.findByText('–')).toBeInTheDocument(); + }); + + it('should display 1080p for full HD resolution', async () => { + (mockPublisher.videoWidth as ReturnType).mockReturnValue(1920); + (mockPublisher.videoHeight as ReturnType).mockReturnValue(1080); + + render(); + + expect(await screen.findByText(/1080p/)).toBeInTheDocument(); + }); +}); + +function render(ui: ReactElement) { + const { wrapper: roomWrapper, ...context } = makeTestProvider( + [providers.user, providers.previewPublisher], + { + userContext: undefined, + previewPublisherContext: { + __onCreated: (ctx) => { + ctx.publisher = mockPublisher; + }, + }, + } + ); + + const wrapper = composeProviders(SuspenseBoundary, roomWrapper); + + return { + ...context, + ...renderBase(ui, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.tsx new file mode 100644 index 00000000..97ada3eb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/VideoStatsOverlay.tsx @@ -0,0 +1,30 @@ +import type { ReactElement } from 'react'; +import usePreviewPublisherContext from '../../../hooks/usePreviewPublisherContext'; +import useVideoStats, { formatResolution, formatFrameRate } from '@core/hooks/useVideoStats'; + +/** + * VideoStatsOverlay Component + * + * Displays the current video resolution and frame rate as a badge + * in the upper-left corner of the waiting room video preview. + * @returns {ReactElement} The VideoStatsOverlay component. + */ +const VideoStatsOverlay = (): ReactElement => { + const { publisher } = usePreviewPublisherContext(); + const { height, frameRate } = useVideoStats(publisher); + + const resolution = formatResolution(height); + const frameRateLabel = formatFrameRate(frameRate); + const label = [resolution, frameRateLabel].filter(Boolean).join(' ') || '–'; + + return ( +
        + {label} +
        + ); +}; + +export default VideoStatsOverlay; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/index.tsx new file mode 100644 index 00000000..e99e84cb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VideoStatsOverlay/index.tsx @@ -0,0 +1 @@ +export { default } from './VideoStatsOverlay'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/VignetteEffect.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/VignetteEffect.tsx new file mode 100644 index 00000000..b0a70b56 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/VignetteEffect.tsx @@ -0,0 +1,26 @@ +import { ReactElement } from 'react'; +import Box from '@mui/material/Box'; +import { VIDEO_CONTAINER_HEIGHT_WR } from '@utils/constants'; + +/** + * VignetteEffect Component + * + * Adds a vignetting effect to the publisher preview to draw the user's attention + * toward the center of the image. + * @returns {ReactElement} - The VignetteEffect component. + */ +const VignetteEffect = (): ReactElement => { + return ( + + ); +}; + +export default VignetteEffect; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/index.tsx new file mode 100644 index 00000000..7e7d61eb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/WaitingRoom/VignetteEffect/index.tsx @@ -0,0 +1,3 @@ +import VignetteEffect from './VignetteEffect'; + +export default VignetteEffect; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/index.ts new file mode 100644 index 00000000..e054347c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/components/index.ts @@ -0,0 +1,29 @@ +export { AdvancedSettingsDialog } from './AdvancedSettingsDialog'; +export { default as AvatarInitials } from './AvatarInitials'; +export { default as Banner } from './Banner'; +export { default as BannerLanguage } from './BannerLanguage'; +export { default as BannerLogo } from './BannerLogo'; +export { default as DeviceAccessAlert } from './DeviceAccessAlert'; +export { default as EnvGuard } from './EnvGuard'; +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as Footer } from './Footer'; +export { default as FooterLinks } from './FooterLinks'; +export { default as GHRepoButton } from './GHRepoButton'; +export { default as HiddenParticipantsTile } from './HiddenParticipantsTile'; +export { default as JoinContainerSeparator } from './JoinContainerSeparator'; +export { default as JoinExistingRoom } from './JoinExistingRoom'; +export { default as JoinWaitRoomButton } from './JoinWaitRoomButton'; +export { default as LandingPageWelcome } from './LandingPageWelcome'; +export { default as LanguageSelector } from './LanguageSelector'; +export { default as MutedAlert } from './MutedAlert'; +export { default as NewRoomButton } from './NewRoomButton'; +export { default as Publisher } from './Publisher'; +export { default as RedirectToUnsupportedBrowserPage } from './RedirectToUnsupportedBrowserPage'; +export { default as RedirectToWaitingRoom } from './RedirectToWaitingRoom'; +export { default as RoomJoinContainer } from './RoomJoinContainer'; +export { default as RoomNameInput } from './RoomNameInput'; +export { default as ScreenShareNameDisplay } from './ScreenShareNameDisplay'; +export { default as ScreenSharingButton } from './ScreenSharingButton'; +export { default as Separator } from './Separator'; +export { default as SoundTest } from './SoundTest'; +export { default as Subscriber } from './Subscriber'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/App.css b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/App.css new file mode 100644 index 00000000..88268eb9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/App.css @@ -0,0 +1,158 @@ +#root { + margin: 0 auto; + text-align: center; + height: 100dvh; + width: 100%; + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* + This animation controls the bar height for the volume indicator of a publisher. + The bar starts at a minimal height of 25% and grows with the audio level + received from the participant. +*/ +@keyframes speech { + 0% { + height: 75%; + } + 100% { + height: 100%; + } +} + +/* + This animation moves an emoji from the bottom of the screen to the top. + The emoji starts with full opacity and fades out as it moves upwards. + As it moves up, the emoji scales down in size, changes its horizontal position, + and rotates side to side, creating a floating effect. +*/ +@keyframes moveEmoji { + 0% { + transform: translateX(0) rotate(0deg); + opacity: 1; + bottom: 0%; + } + + 20% { + transform: translateX(2rem) rotate(-8deg); + } + + 50% { + transform: translateX(4rem) rotate(16deg) scale(1); + opacity: 1; + } + + 75% { + transform: translateX(-2rem) rotate(-20deg) scale(0.5); + } + + 100% { + transform: translateX(0px) rotate(0deg) scale(0.2); + opacity: 0; + bottom: 100%; + } +} + +/* + HiddenParticipantsTile styles: + These rules style the avatar group shown when participants are hidden. + - The default border color is a dark gray. + - On hover, the border color becomes lighter to indicate interactivity. + - Smooth transition is applied to the border color for a subtle effect. +*/ +#hidden-participants .MuiAvatarGroup-root .MuiAvatar-root { + border-color: rgb(60, 64, 67); + transition-property: border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +#hidden-participants:hover .MuiAvatarGroup-root .MuiAvatar-root { + border-color: rgb(76, 80, 82); +} + +.choose-background-effect-grid { + width: 100%; + max-height: 100%; + overflow-y: auto; + padding: 16px; + min-height: 0; +} + +.choose-background-effect-grid-waiting { + width: 100%; + overflow-y: auto; + padding: 8px; + max-height: 35vh; +} +@media (min-width: 899px) and (min-height: 920px) { + .choose-background-effect-grid-waiting { + max-height: 55vh; + } +} +@media (max-height: 875px) { + .choose-background-effect-grid-waiting { + max-height: 34vh; + } +} +@media (max-height: 750px) { + .choose-background-effect-grid-waiting { + max-height: 35vh; + } +} +@media (max-width: 600px) { + .choose-background-effect-grid-waiting { + max-height: 25vh; + } +} +@media (max-width: 425px) { + .choose-background-effect-grid-waiting { + max-height: 32vh; + } +} +@media (max-height: 680px) { + .choose-background-effect-grid-waiting { + max-height: 44vh; + } +} + +.add-background-effect-input .MuiInputBase-root { + color: #5f6368; +} + +.add-background-effect-input .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input input::placeholder { + color: #5f6368; + opacity: 1; +} + +.add-background-effect-input-icon { + color: #5f6368; +} + +.file-upload-drop-area-text { + font-size: 1rem; +} +@media (max-width: 375px) and (max-height: 667px) { + .file-upload-drop-area-text { + font-size: 0.9rem !important; + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/index.css b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/index.css new file mode 100644 index 00000000..ecbd2b99 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/css/index.css @@ -0,0 +1,38 @@ +@import 'tailwindcss'; + +/* Tailwind CSS v4 config directive */ +@config '../../tailwind.config.cjs'; + +/* Libs source - outside frontend, needs explicit @source */ +@source "../../../libs/ui/**/*.tsx"; + +@layer base { + /* + * The default border color has changed to `currentcolor` in Tailwind CSS v4, + * so we've added these compatibility styles to make sure everything still + * looks the same as it did with Tailwind CSS v3. + */ + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + + /* Default body text color to avoid black text on dark backgrounds */ + body { + color: var(--vera-text-secondary); + } + + /* + Backward compatibility after migrating from mui5 to mui9 + */ + .vera-dark-mode input:-webkit-autofill { + box-shadow: rgb(38, 103, 152) 0px 0px 0px 100px inset !important; + } + + .Mui-selected { + background-color: rgba(153, 65, 255, 0.08) !important; + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/env.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/env.ts new file mode 100644 index 00000000..2a35feb1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/env.ts @@ -0,0 +1,247 @@ +import { z } from 'zod'; +import { LAYOUT_MODES, type LayoutMode } from './types/session'; + +declare const __APP_ENV__: Record; + +export type Lang = 'en' | 'it' | 'es' | 'es-MX' | 'en-US' | 'de'; + +export const RESOLUTIONS = [ + '1920x1080', + '1280x960', + '1280x720', + '640x480', + '640x360', + '320x240', + '320x180', +] as const; + +export type Resolution = (typeof RESOLUTIONS)[number]; + +export type Mode = 'development' | 'production' | 'test'; + +export type EnvArg = { + [key: string]: unknown; +}; + +const langValues = ['en', 'it', 'es', 'es-MX', 'en-US', 'de'] as const satisfies readonly Lang[]; +const langEnum = z.enum([...langValues] as [Lang, ...Lang[]]); + +const boolField = (defaultValue: boolean) => + z.preprocess((val) => { + if (val === undefined || val === null || val === '') return defaultValue; + if (typeof val === 'boolean') return val; + if (val === 'true') return true; + if (val === 'false') return false; + return val; + }, z.boolean()); + +const positiveIntField = (defaultValue: number) => + z.preprocess( + (val) => (val === undefined || val === null || val === '' ? defaultValue : Number(val)), + z.number().int().positive() + ); + +const intListField = (fallback: number[]) => + z.preprocess((val) => { + if (!val) return fallback; + if (typeof val !== 'string') throw new Error('Expected pipe-separated integer list'); + return val.split('|').map((v) => { + const n = Number(v.trim()); + if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid integer value in list: ${v}`); + return n; + }); + }, z.array(z.number().int().positive())); + +const optionalStringField = z.preprocess( + (val) => (val === undefined || val === null || val === '' ? undefined : val), + z.string().optional() +); + +const langListField = (fallback: Lang) => + z.preprocess((val) => { + if (!val) return [fallback]; + if (typeof val !== 'string') throw new Error('Invalid I18N_SUPPORTED_LANGUAGES'); + return val.split('|').map((l) => { + const lang = l.trim(); + if (!langValues.includes(lang as Lang)) + throw new Error(`Invalid supported language: ${lang}`); + return lang; + }); + }, z.array(langEnum)); + +const envSchema = z + .object({ + I18N_FALLBACK_LANGUAGE: z.preprocess( + (v) => (v === undefined || v === null || v === '' ? 'en' : v), + langEnum + ), + I18N_SUPPORTED_LANGUAGES: z.union([z.string(), z.null(), z.undefined()]).optional(), + ENABLE_REPORT_ISSUE: boolField(false), + ALLOW_BACKGROUND_EFFECTS: boolField(true), + ALLOW_CAMERA_CONTROL: boolField(true), + ALLOW_VIDEO_ON_JOIN: boolField(true), + DEFAULT_RESOLUTION: z.preprocess( + (v) => (v === undefined || v === null || v === '' ? undefined : v), + z.enum([...RESOLUTIONS] as [Resolution, ...Resolution[]]).optional() + ), + PUBLISHER_MAX_RESOLUTION: z.preprocess( + (v) => (v === undefined || v === null || v === '' ? '1920x1080' : v), + z.enum([...RESOLUTIONS] as [Resolution, ...Resolution[]]) + ), + NOTIFICATION_DURATION_MS: positiveIntField(4_000), + MIN_CUSTOM_VIDEO_BITRATE_BPS: positiveIntField(5_000), + MAX_CUSTOM_VIDEO_BITRATE_BPS: positiveIntField(10_000_000), + SUPPORTED_FRAME_RATES: intListField([30, 15, 7, 1]), + ALLOW_ADVANCED_NOISE_SUPPRESSION: boolField(true), + ALLOW_AUDIO_ON_JOIN: boolField(true), + ALLOW_MICROPHONE_CONTROL: boolField(true), + MEETING_ROOM_ALLOW_ADVANCED_SETTINGS: boolField(false), + WAITING_ROOM_ALLOW_ADVANCED_SETTINGS: boolField(false), + WAITING_ROOM_ALLOW_DEVICE_SELECTION: boolField(true), + ALLOW_ARCHIVING: boolField(true), + ALLOW_CAPTIONS: boolField(true), + ALLOW_CHAT: boolField(true), + MEETING_ROOM_ALLOW_DEVICE_SELECTION: boolField(true), + ALLOW_EMOJIS: boolField(true), + ALLOW_SCREEN_SHARE: boolField(true), + DEFAULT_LAYOUT_MODE: z.preprocess( + (v) => (v === undefined || v === null || v === '' ? 'active-speaker' : v), + z.enum([...LAYOUT_MODES] as [LayoutMode, ...LayoutMode[]]) + ), + SHOW_PARTICIPANT_LIST: boolField(true), + SHOW_VIDEO_STATS: boolField(false), + BYPASS_WAITING_ROOM: boolField(false), + API_URL: z.preprocess((v) => (v === undefined || v === null || v === '' ? '' : v), z.string()), + TUNNEL_DOMAIN: optionalStringField, + AVOID_FETCHING_APP_CONFIG: boolField(true), + MODE: z.preprocess( + (v) => v ?? 'development', + z.enum(['development', 'production', 'test'] as const) + ), + VONAGE_VIDEO_HOST: optionalStringField, + }) + .transform(({ I18N_FALLBACK_LANGUAGE, I18N_SUPPORTED_LANGUAGES, ...rest }) => ({ + ...rest, + I18N_FALLBACK_LANGUAGE, + I18N_SUPPORTED_LANGUAGES: langListField(I18N_FALLBACK_LANGUAGE).parse(I18N_SUPPORTED_LANGUAGES), + })); + +export class Env { + private raw: Partial; + private initialRaw: Partial; + + public ENABLE_REPORT_ISSUE!: boolean; + public I18N_FALLBACK_LANGUAGE!: Lang; + public I18N_SUPPORTED_LANGUAGES!: Lang[]; + + public ALLOW_BACKGROUND_EFFECTS!: boolean; + public ALLOW_CAMERA_CONTROL!: boolean; + public ALLOW_VIDEO_ON_JOIN!: boolean; + public DEFAULT_RESOLUTION!: Resolution | undefined; + public PUBLISHER_MAX_RESOLUTION!: Resolution; + public NOTIFICATION_DURATION_MS!: number; + public MIN_CUSTOM_VIDEO_BITRATE_BPS!: number; + public MAX_CUSTOM_VIDEO_BITRATE_BPS!: number; + public SUPPORTED_FRAME_RATES!: number[]; + public ALLOW_ADVANCED_NOISE_SUPPRESSION!: boolean; + public ALLOW_AUDIO_ON_JOIN!: boolean; + public ALLOW_MICROPHONE_CONTROL!: boolean; + public MEETING_ROOM_ALLOW_ADVANCED_SETTINGS!: boolean; + public WAITING_ROOM_ALLOW_ADVANCED_SETTINGS!: boolean; + public WAITING_ROOM_ALLOW_DEVICE_SELECTION!: boolean; + public ALLOW_ARCHIVING!: boolean; + public ALLOW_CAPTIONS!: boolean; + public ALLOW_CHAT!: boolean; + public MEETING_ROOM_ALLOW_DEVICE_SELECTION!: boolean; + public ALLOW_EMOJIS!: boolean; + public ALLOW_SCREEN_SHARE!: boolean; + public DEFAULT_LAYOUT_MODE!: LayoutMode; + public SHOW_PARTICIPANT_LIST!: boolean; + public SHOW_VIDEO_STATS!: boolean; + public BYPASS_WAITING_ROOM!: boolean; + public API_URL!: string; + public TUNNEL_DOMAIN!: string | undefined; + public AVOID_FETCHING_APP_CONFIG!: boolean; + public MODE!: Mode; + public VONAGE_VIDEO_HOST!: string | undefined; + + constructor(env: Record) { + this.raw = { ...env }; + this.initialRaw = { ...env }; + + const parsed = envSchema.parse(env); + Object.assign(this, parsed); + + this.setApiUrl(parsed.API_URL); + } + + /** + * Partially updates the environment variables at runtime. + * Useful for testing different configurations without reloading the page. + * @param {Partial} partial - An object containing the env variables to update. + */ + partialUpdate(partial: Partial) { + this.raw = { + ...this.raw, + ...partial, + }; + const next = new Env(this.raw); + const savedInitialRaw = this.initialRaw; + Object.assign(this, next); + this.initialRaw = savedInitialRaw; + } + + /** + * Resets all environment variables to their initial values when the Env instance was created. + * Useful for cleaning up after tests that call partialUpdate. + */ + reset() { + const next = new Env(this.initialRaw); + this.raw = { ...this.initialRaw }; + Object.assign(this, next); + } + + /** + * Sets API_URL based on the provided envUrl or defaults to window.location.origin. + * If envUrl is empty or undefined, it defaults to window.location.origin. + * If window.location.origin includes 'localhost', it defaults to 'http://localhost:3345' to account for typical local API setups. + * @param envUrl - The raw environment variable value for API_URL, which can be empty or undefined. + */ + setApiUrl(envUrl: string | undefined) { + const url = (() => { + if (!envUrl?.trim()) { + return window.location.origin.includes('localhost') + ? 'http://localhost:3345' + : window.location.origin; + } + + return envUrl; + })(); + + this.API_URL = url; + } + + /** + * Parses a '|'-separated string of language codes and updates I18N_SUPPORTED_LANGUAGES. + * Throws if any language code is not in the supported list. + * Falls back to I18N_FALLBACK_LANGUAGE when the value is empty or undefined. + * @param {unknown} value - Raw language string, e.g. "en|es|it". + */ + setSupportedLanguages(value: unknown) { + this.I18N_SUPPORTED_LANGUAGES = langListField(this.I18N_FALLBACK_LANGUAGE).parse(value); + } +} + +let env: Env; +let envInitError: Error | null = null; + +try { + env = new Env(__APP_ENV__); +} catch (error) { + envInitError = error instanceof Error ? error : new Error(String(error)); + // Provide a safe shell so that callers that import `env` at module level + // don't throw a second time on property access before EnvGuard re-throws. + env = {} as Env; +} + +export { env, envInitError }; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useAudioLevels.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useAudioLevels.spec.tsx new file mode 100644 index 00000000..484fe982 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useAudioLevels.spec.tsx @@ -0,0 +1,116 @@ +import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import { Publisher } from '@vonage/client-sdk-video'; +import EventEmitter from 'events'; + +import { PublisherContextType } from '../../Context/PublisherProvider'; +import useAudioLevels from '../useAudioLevels'; +import usePublisherContext from '../usePublisherContext'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; + +vi.mock('../usePublisherContext.tsx'); + +const mockUsePublisherContext = usePublisherContext as Mock<[], PublisherContextType>; + +describe('usePublisherAudioLevels', () => { + let advanceDateNow: (ms: number) => void; + let mockPublisher: EventEmitter; + + beforeEach(() => { + let now = Date.now(); + + const dateNowSpy = vi.spyOn(global.Date, 'now').mockReturnValue(now); + advanceDateNow = (ms) => { + now += ms; + dateNowSpy.mockReturnValue(now); + }; + + mockPublisher = new EventEmitter(); + + const mockPublisherContext = { + publisher: mockPublisher as unknown as Publisher, + isPublishing: true, + } as PublisherContextType; + + mockUsePublisherContext.mockImplementation(() => mockPublisherContext); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should set the audioLevel correctly', async () => { + const { result } = renderHook(() => useAudioLevels()); + // initially the audio level is set to 0 in the hook state + expect(result.current).toBe(0); + + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.11 }); + }); + advanceDateNow(50); + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.11 }); + }); + + // audio level should now not be 0 given we've received audio level events + await waitFor(() => expect(result.current).toBeGreaterThan(0)); + + advanceDateNow(51); + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.11 }); + }); + + await waitFor(() => expect(result.current).toBeGreaterThan(0)); + }); + + it('should throttle audio level updates', async () => { + const { result } = renderHook(() => useAudioLevels()); + expect(result.current).toBe(0); + + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.11 }); + }); + advanceDateNow(50); + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.12 }); + }); + + await waitFor(() => expect(result.current).toBeGreaterThan(0)); + + // Check if the throttling works + const initialLevel = result.current; + + advanceDateNow(20); + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.13 }); + }); + + // Since it's throttled, the level should not have changed yet + await waitFor(() => expect(result.current).toBe(initialLevel)); + + advanceDateNow(100); + act(() => { + mockPublisher.emit('audioLevelUpdated', { audioLevel: 0.14 }); + }); + + await waitFor(() => expect(result.current).toBeGreaterThan(initialLevel)); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; +}; + +function renderHook( + render: (initialProps: Props) => Result, + { userContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider([providers.user], { + userContext, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useChat.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useChat.spec.tsx new file mode 100644 index 00000000..28bf0954 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useChat.spec.tsx @@ -0,0 +1,63 @@ +import { act, renderHook as renderHookBase } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import useChat from '../useChat'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; + +const mockSignal = vi.fn(); + +describe('useChat', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('onChatMessage should parse message and update messages state', () => { + const { result } = renderHook(() => useChat({ signal: mockSignal })); + + act(() => { + result.current.onChatMessage('{"participantName":"Remote User","text":"Hello!"}'); + }); + + expect(result.current.messages[0]).toMatchObject({ + participantName: 'Remote User', + message: 'Hello!', + timestamp: expect.any(Number), + }); + }); + + it('sendChatMessage should send message via signal', () => { + const { result } = renderHook(() => useChat({ signal: mockSignal }), { + userContext: { + value: { + defaultSettings: { + name: 'Local User', + }, + }, + }, + }); + + result.current.sendChatMessage('Hello there!'); + + expect(mockSignal).toHaveBeenCalledWith({ + type: 'chat', + data: '{"participantName":"Local User","text":"Hello there!"}', + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; +}; + +function renderHook( + render: (initialProps: Props) => Result, + { userContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider([providers.user], { + userContext, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useCollectBrowserInformation.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useCollectBrowserInformation.spec.tsx new file mode 100644 index 00000000..7ce79ea9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useCollectBrowserInformation.spec.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook as renderHookBase } from '@testing-library/react'; +import useCollectBrowserInformation from '../useCollectBrowserInformation'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import EventEmitter from 'events'; +import type VonageVideoClient from '@utils/VonageVideoClient'; + +describe('useCollectBrowserInformation', () => { + beforeEach(() => { + // Mock navigator properties + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('FakeUserAgent'); + vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US'); + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(true); + + // Mock window properties + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1920); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1080); + vi.spyOn(window.screen, 'width', 'get').mockReturnValue(2560); + vi.spyOn(window.screen, 'height', 'get').mockReturnValue(1440); + + // Mock window.location with all required properties + const fakeUrl = { href: 'https://awesome-website.com' }; + vi.spyOn(window, 'location', 'get').mockReturnValue(fakeUrl as unknown as Location); + + // Mock document properties + vi.spyOn(document, 'referrer', 'get').mockReturnValue('https://awesome-referrer.com'); + + // Mock the date format + vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ + timeZone: 'America/Chicago', + locale: 'en-US', + calendar: 'gregory', + numberingSystem: 'latn', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should collect all browser information correctly', () => { + const mockVonageVideoClient = Object.assign(new EventEmitter(), { + get sessionId() { + return 'someSessionId'; + }, + get connectionId() { + return 'yourConnectionId'; + }, + }) as VonageVideoClient; + + const { result } = render({ + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient; + } + }, + }, + }); + + expect(result.current).toEqual({ + sessionId: 'someSessionId', + browser: 'FakeUserAgent', + screenResolution: '2560x1440', + referrer: 'https://awesome-referrer.com', + currentUrl: 'https://awesome-website.com', + timeZone: 'America/Chicago', + language: 'en-US', + isOnline: true, + cookiesEnabled: true, + windowSize: { + width: 1920, + height: 1080, + }, + connectionId: 'yourConnectionId', + }); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render({ sessionContext, userContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderHookBase(() => useCollectBrowserInformation(), { + wrapper, + }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDateTime.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDateTime.spec.tsx new file mode 100644 index 00000000..6630935a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDateTime.spec.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import useDateTime from '../useDateTime'; + +describe('useDateTime', () => { + it('converts 21:12 to "9:12 PM"', () => { + const rawDate = new Date('June 27, 2024 21:12:00'); + vi.setSystemTime(rawDate); + const { result } = renderHook(() => useDateTime()); + + expect(result.current.time).toBe('9:12 PM'); + }); + + it('converts 0:15 to "12:15 AM"', () => { + const rawDate = new Date('June 27, 2024 0:15:00'); + vi.setSystemTime(rawDate); + const { result } = renderHook(() => useDateTime()); + + expect(result.current.time).toBe('12:15 AM'); + }); + + it('uses the correct date format', () => { + const rawDate = new Date('June 27, 2024 03:24:00'); + vi.setSystemTime(rawDate); + const { result } = renderHook(() => useDateTime()); + + expect(result.current.date).toBe('Thu, Jun 27'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDropdownResizeObserver.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDropdownResizeObserver.spec.tsx new file mode 100644 index 00000000..24343ef9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useDropdownResizeObserver.spec.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { Dispatch, SetStateAction } from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; +import useDropdownResizeObserver from '../useDropdownResizeObserver'; + +vi.mock('resize-observer-polyfill', () => ({ + default: vi.fn((cb: ResizeObserverCallback) => { + return { + observe: vi.fn().mockImplementation((target: HTMLElement) => { + const entry: ResizeObserverEntry = { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }], + contentBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }], + devicePixelContentBoxSize: [ + { inlineSize: target.clientWidth, blockSize: target.clientHeight }, + ], + }; + cb([entry], this as unknown as ResizeObserver); + }), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }), +})); + +describe('useDropdownResizeObserver', () => { + const mockSetIsOpen = vi.fn() as Dispatch>; + let mockDropdownRefElement: HTMLElement; + + it('should initialize the resize observer', () => { + mockDropdownRefElement = document.createElement('div'); + + renderHook(() => + useDropdownResizeObserver({ + dropDownRefElement: mockDropdownRefElement, + setIsOpen: mockSetIsOpen, + }) + ); + expect(ResizeObserver).toHaveBeenCalled(); + }); + + it('should call the function to close the dropdown when the window is smaller than the dropdown', () => { + mockDropdownRefElement = document.createElement('div'); + Object.defineProperty(mockDropdownRefElement, 'offsetHeight', { + configurable: true, + writable: true, + value: 50, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + writable: true, + value: 500, + }); + + renderHook(() => + useDropdownResizeObserver({ + dropDownRefElement: mockDropdownRefElement, + setIsOpen: mockSetIsOpen, + }) + ); + + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useElementDimensions.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useElementDimensions.spec.tsx new file mode 100644 index 00000000..d5a5358f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useElementDimensions.spec.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { RefObject } from 'react'; +import useElementDimensions from '../useElementDimensions'; + +describe('useElementDimensions', () => { + let elementRef: RefObject; + + beforeEach(() => { + elementRef = { current: document.createElement('div') }; + Object.defineProperty(elementRef.current, 'offsetWidth', { + configurable: true, + writable: true, + value: 1, + }); + + Object.defineProperty(elementRef.current, 'offsetHeight', { + configurable: true, + writable: true, + value: 2, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initially returns dimensions as { width: 0, height: 0 }', () => { + const { result } = renderHook(() => useElementDimensions({ elementRef })); + expect(result.current).toEqual({ width: 0, height: 0 }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useEmoji.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useEmoji.spec.tsx new file mode 100644 index 00000000..16f98d93 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useEmoji.spec.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { Connection } from '@vonage/client-sdk-video'; +import useEmoji, { EmojiWrapper } from '../useEmoji'; +import { SignalEvent, SubscriberWrapper } from '../../types/session'; + +const mockSignal = vi.fn(); +const mockGetConnectionId = vi.fn(); +const mockConnection = { connectionId: '456' } as Connection; + +describe('useEmoji', () => { + beforeEach(() => { + mockGetConnectionId.mockReturnValue('123'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + describe('sendEmoji', () => { + it('calls Session.signal with the emoji and current time', () => { + vi.setSystemTime(12_000_000); + const { result } = renderHook(() => + useEmoji({ signal: mockSignal, getConnectionId: mockGetConnectionId }) + ); + + act(() => { + result.current.sendEmoji('❤️'); + }); + + expect(mockSignal).toBeCalledTimes(1); + expect(mockSignal).toBeCalledWith({ + type: 'emoji', + data: '{"emoji":"❤️","time":12000000}', + }); + }); + + it('when called multiple times, sendEmoji throttles calls to once every 500ms', () => { + vi.useFakeTimers(); + const { result } = renderHook(() => + useEmoji({ signal: mockSignal, getConnectionId: mockGetConnectionId }) + ); + + act(() => { + result.current.sendEmoji('❤️'); + result.current.sendEmoji('❤️'); + }); + + expect(mockSignal).toBeCalledTimes(1); + + vi.advanceTimersByTime(250); + expect(mockSignal).toBeCalledTimes(1); + + vi.advanceTimersByTime(251); + act(() => { + result.current.sendEmoji('❤️'); + }); + expect(mockSignal).toBeCalledTimes(2); + }); + }); + + it('adds emojis to the queue when a signal event is received and gets the correct sender name', async () => { + const { result } = renderHook(() => + useEmoji({ signal: mockSignal, getConnectionId: mockGetConnectionId }) + ); + + // Mock receiving a signal event from another user + act(() => { + const signalEvent: SignalEvent = { + type: 'signal:emoji', + data: JSON.stringify({ + emoji: '❤️', + time: Date.now(), + connectionId: mockConnection.connectionId, // Different from the session connection + }) as unknown as string, + from: { connectionId: '456', creationTime: 1, data: 'some-data' }, + }; + const subscriberWrapper: SubscriberWrapper = { + subscriber: { + stream: { + connection: { + connectionId: '456', + }, + name: 'John Doe', + }, + }, + } as unknown as SubscriberWrapper; + const subscriberWrappers = [subscriberWrapper]; + result.current.onEmoji(signalEvent, subscriberWrappers); + }); + + const expectedEmojiWrapper: EmojiWrapper = { + name: 'John Doe', // The mock connection user + emoji: '❤️', + time: expect.any(Number), + }; + + // Use waitFor to check if emojiQueue contains the expected emoji + await waitFor(() => { + expect(result.current.emojiQueue).toContainEqual(expectedEmojiWrapper); + }); + }); + + it('recognizes when a received signal event is from local user', async () => { + const { result } = renderHook(() => + useEmoji({ signal: mockSignal, getConnectionId: mockGetConnectionId }) + ); + + // Mock receiving a signal event from local user + act(() => { + const signalEvent: SignalEvent = { + type: 'signal:emoji', + data: JSON.stringify({ + emoji: '😲', + time: Date.now(), + connectionId: mockConnection.connectionId, // Different from the session connection + }) as unknown as string, + from: { connectionId: '123', creationTime: 1, data: 'some-data' }, + }; + const subscriberWrapper: SubscriberWrapper = { + subscriber: { + stream: { + connection: { + connectionId: '123', + }, + name: 'That be I', + }, + }, + } as unknown as SubscriberWrapper; + const subscriberWrappers = [subscriberWrapper]; + result.current.onEmoji(signalEvent, subscriberWrappers); + }); + + const expectedEmojiWrapper: EmojiWrapper = { + name: 'You', + emoji: '😲', + time: expect.any(Number), + }; + + // Use waitFor to check if emojiQueue contains the expected emoji + await waitFor(() => { + expect(result.current.emojiQueue).toContainEqual(expectedEmojiWrapper); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useGoodByePage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useGoodByePage.spec.tsx new file mode 100644 index 00000000..77c2d886 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useGoodByePage.spec.tsx @@ -0,0 +1,133 @@ +import { renderHook as renderHookBase, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { QueryClient } from '@tanstack/react-query'; +import useGoodByePage from '../useGoodByePage'; +import { useLocation, useParams } from 'react-router-dom'; +import { availableArchive } from '../../api/archiving/tests/data'; +import { makeTestProvider, providers, type ProviderOptions } from '@core-test/providers'; +import { makeVideoClientMock } from '@core-test/fixtures'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: vi.fn(() => ({ state: null, search: '', pathname: '/goodbye', hash: '' })), + useParams: vi.fn(() => ({ roomIdentifier: 'my-session-key' })), + }; +}); + +const mockUseParams = useParams as Mock; +const mockUseLocation = useLocation as Mock; + +type RenderOptions = { + runtimeContext?: ProviderOptions['RuntimeContext']; +}; + +function renderHook(render: () => Result, { runtimeContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider([providers.runtime], { + runtimeContext, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} + +describe('useGoodByePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams.mockReturnValue({ roomIdentifier: 'my-session-key' }); + }); + + it('returns the sessionKey from URL params', () => { + const videoClient = makeVideoClientMock({ + searchArchives: Promise.resolve({ items: [], count: 0 }), + }); + const { result } = renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + expect(result.current.sessionKey).toBe('my-session-key'); + }); + + it('fetches archives from videoClient with sessionKey', async () => { + const videoClient = makeVideoClientMock({ + searchArchives: vi.fn().mockResolvedValue({ items: [], count: 0 }), + }); + + renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + + await waitFor(() => { + expect(videoClient.searchArchives).toHaveBeenCalled(); + }); + + expect(videoClient.searchArchives).toHaveBeenCalledWith({ + sessionKey: 'my-session-key', + count: undefined, + offset: undefined, + }); + }); + + it('forwards archives from videoClient', async () => { + const videoClient = makeVideoClientMock({ + searchArchives: Promise.resolve({ + items: [availableArchive], + count: 1, + } as unknown as ReturnType), + }); + const { result } = renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + + await waitFor(() => { + expect(result.current.archives).not.toBe('error'); + }); + + expect(result.current.archives).toEqual([availableArchive]); + }); + + it('forwards "error" from videoClient when archives fail to load', async () => { + const videoClient = makeVideoClientMock({ + searchArchives: vi.fn().mockRejectedValue(new Error('Failed')), + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 0 } } }); + const { result } = renderHook(() => useGoodByePage(), { + runtimeContext: { videoClient, queryClient }, + }); + + await waitFor(() => { + expect(result.current.archives).toBe('Failed'); + }); + }); + + it('uses location.state header when provided', () => { + mockUseLocation.mockReturnValue({ + state: { header: 'Custom Header', caption: 'Custom Caption' }, + }); + + const videoClient = makeVideoClientMock({ + searchArchives: Promise.resolve({ items: [], count: 0 }), + }); + const { result } = renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + expect(result.current.header).toBe('Custom Header'); + expect(result.current.caption).toBe('Custom Caption'); + }); + + it('falls back to default i18n header when location.state is null', () => { + mockUseLocation.mockReturnValue({ state: null }); + + const videoClient = makeVideoClientMock({ + searchArchives: Promise.resolve({ items: [], count: 0 }), + }); + const { result } = renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + expect(typeof result.current.header).toBe('string'); + expect(result.current.header.length).toBeGreaterThan(0); + }); + + it('falls back to default i18n caption when location.state has no caption', () => { + mockUseLocation.mockReturnValue({ state: { header: 'Some Header' } }); + + const videoClient = makeVideoClientMock({ + searchArchives: Promise.resolve({ items: [], count: 0 }), + }); + const { result } = renderHook(() => useGoodByePage(), { runtimeContext: { videoClient } }); + expect(typeof result.current.caption).toBe('string'); + expect(result.current.caption.length).toBeGreaterThan(0); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsSmallViewport.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsSmallViewport.spec.tsx new file mode 100644 index 00000000..da0527e2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsSmallViewport.spec.tsx @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterAll } from 'vitest'; +import useIsSmallViewport from '../useIsSmallViewport'; +import { SMALL_VIEWPORT } from '../../utils/constants'; + +const matchMediaCommon = { + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +}; + +describe('useIsSmallViewport', () => { + const originalMatchMedia = globalThis.matchMedia; + + beforeEach(() => { + globalThis.matchMedia = vi.fn((query) => ({ + matches: new RegExp(`\\(max-width:\\s*${SMALL_VIEWPORT + 1}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + }); + + afterAll(() => { + globalThis.matchMedia = originalMatchMedia; + }); + + it('should return false when window width is greater than 768px', () => { + const { result } = renderHook(() => useIsSmallViewport()); + + expect(result.current).toBe(false); + }); + + it('should return true when window width is less than or equal to 768px', () => { + globalThis.matchMedia = vi.fn((query: string) => ({ + matches: new RegExp(`\\(max-width:\\s*${SMALL_VIEWPORT}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + + const { result } = renderHook(() => useIsSmallViewport()); + + expect(result.current).toBe(true); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx new file mode 100644 index 00000000..b3c4648f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useIsTabletViewport.spec.tsx @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterAll } from 'vitest'; +import useIsTabletViewport from '../useIsTabletViewport'; +import { TABLET_VIEWPORT } from '../../utils/constants'; + +const matchMediaCommon = { + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), +}; + +describe('useIsTabletViewport', () => { + const originalMatchMedia = globalThis.matchMedia; + + beforeEach(() => { + globalThis.matchMedia = vi.fn((query) => ({ + matches: new RegExp(`\\(max-width:\\s*${TABLET_VIEWPORT + 1}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + }); + + afterAll(() => { + globalThis.matchMedia = originalMatchMedia; + }); + + it('should return false when globalThis width is greater than 899px', () => { + const { result } = renderHook(() => useIsTabletViewport()); + + expect(result.current).toBe(false); + }); + + it('should return true when globalThis width is less than or equal to 899px', () => { + globalThis.matchMedia = vi.fn((query: string) => ({ + matches: new RegExp(`\\(max-width:\\s*${TABLET_VIEWPORT}px\\)`).test(query), + media: query, + ...matchMediaCommon, + })); + const { result } = renderHook(() => useIsTabletViewport()); + + expect(result.current).toBe(true); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useLayoutManager.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useLayoutManager.spec.tsx new file mode 100644 index 00000000..d533c7db --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useLayoutManager.spec.tsx @@ -0,0 +1,44 @@ +import { describe, it, vi } from 'vitest'; +import type * as OpentokLayout from 'opentok-layout-js'; + +const layoutContainer: OpentokLayout.LayoutContainer = { + getLayout: vi.fn(), + layout: vi.fn(), + setOptions: vi.fn(), +}; + +vi.mock('opentok-layout-js', () => { + const OpenTokLayoutManager = vi.fn(() => layoutContainer); + + return { __esModule: true, default: OpenTokLayoutManager, Box: vi.fn(), Element: vi.fn() }; +}); + +import useLayoutManager from '../useLayoutManager'; +import { renderHook } from '@testing-library/react'; +import OpenTokLayoutManager from 'opentok-layout-js'; + +describe('useLayoutManager', () => { + it('returns a memoized getLayout function that delegates to OpenTokLayoutManager', () => { + const { result } = renderHook(() => useLayoutManager()); + + const elements = [{}, {}] as OpentokLayout.Element[]; + const containerDimensions = { height: 600, width: 800 }; + const shouldMakeLargeTilesLandscape = false; + + const layout = result.current(containerDimensions, elements, shouldMakeLargeTilesLandscape); + + expect(layoutContainer.getLayout).toHaveBeenCalledWith(elements); + expect(OpenTokLayoutManager).toHaveBeenCalledTimes(1); + + expect(OpenTokLayoutManager).toHaveBeenCalledWith( + expect.objectContaining({ + bigMaxRatio: 3 / 2, // hard code ratio when shouldMakeLargeTilesLandscape is false + containerWidth: containerDimensions.width, + containerHeight: containerDimensions.height, + }) + ); + + // make sure there is a return value + expect(layout).toBeDefined(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useMeetingRoom.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useMeetingRoom.spec.tsx new file mode 100644 index 00000000..a911674b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useMeetingRoom.spec.tsx @@ -0,0 +1,198 @@ +import { waitFor, renderHook as renderHookBase } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import useMeetingRoom from '../useMeetingRoom'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import MemoryRouter from '@test/RouterWrapper'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import { env } from '../../env'; +import type { VideoClient } from '@core/services'; + +const mockSessionId = '1_MX4xMjM0NTY3OH4-VGh1IEZlYiAyNyAwODozMjozNCBQU1QgMjAyMH4wLjI0NDYxMjE'; +const validSessionKey = + 'eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOiIxX01YNHhNak0wTlRZM09INC1WR2gxSUVabFlpQXlOeUF3T0Rvek1qb3pOQ0JRVTFRZ01qQXlNSDR3TGpJME5EWXhNakUiLCJyb29tTmFtZSI6IlRlc3RDb21wb25lbnRSb29tIn0.fakesig'; + +const { mockCreateSessionMutate, mockJoinSessionMutate, mockVideoClient } = vi.hoisted(() => { + const mockCreateSessionMutate = vi.fn(); + const mockJoinSessionMutate = vi.fn(); + const mockVideoClient = { + createSession: (...args: unknown[]) => mockCreateSessionMutate(...args) as unknown, + joinSession: (...args: unknown[]) => mockJoinSessionMutate(...args) as unknown, + }; + return { mockCreateSessionMutate, mockJoinSessionMutate, mockVideoClient }; +}); + +const mockNavigate = vi.fn(); +const mockLocation = { search: '' }; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + useLocation: () => mockLocation, + useParams: () => ({ roomIdentifier: 'test-room' }), + }; +}); + +vi.mock('../useIsSmallViewport', () => ({ + default: () => false, +})); + +vi.mock('../useScreenShare', () => ({ + default: () => ({ + isSharingScreen: false, + screensharingPublisher: null, + screenshareVideoElement: null, + toggleShareScreen: vi.fn(), + }), +})); + +vi.mock('../useBackgroundPublisherContext', () => ({ + default: () => ({ + initBackgroundLocalPublisher: vi.fn(), + publisher: null, + accessStatus: DEVICE_ACCESS_STATUS.ACCEPTED, + }), +})); + +describe('useMeetingRoom', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLocation.search = ''; + env.BYPASS_WAITING_ROOM = false; + mockCreateSessionMutate.mockResolvedValue({ sessionKey: validSessionKey }); + mockJoinSessionMutate.mockResolvedValue({ token: 'mock-token', sessionId: mockSessionId }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns all expected fields', async () => { + const { result } = renderHook(() => useMeetingRoom()); + + await waitFor(() => { + expect(result.current).toMatchObject({ + isSharingScreen: false, + subscriberWrappers: [], + reconnecting: false, + isRecording: false, + isVideoEnabled: true, + }); + }); + }); + + it('isRecording is true when archiveId is set', async () => { + const { result } = renderHook(() => useMeetingRoom(), { + sessionContext: { initialValue: { archiveId: 'archive-123' } }, + }); + + await waitFor(() => { + expect(result.current.isRecording).toBe(true); + }); + }); + + it('navigates to waiting room when username is missing and bypass is false', async () => { + renderHook(() => useMeetingRoom(), { + userContext: { value: { defaultSettings: { name: '' } } }, + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/waiting-room/test-room'); + }); + }); + + it('does not navigate to waiting room when bypass is true', async () => { + mockLocation.search = '?bypass=true'; + const mockJoinRoom = vi.fn(); + + renderHook(() => useMeetingRoom(), { + userContext: { value: { defaultSettings: { name: '' } } }, + sessionContext: { + __interceptor: (ctx) => { + if (ctx) ctx.joinRoom = mockJoinRoom; + }, + }, + }); + + await waitFor(() => { + expect(mockJoinRoom).toHaveBeenCalledWith({ sessionKey: validSessionKey }); + }); + expect(mockNavigate).not.toHaveBeenCalledWith('/waiting-room/test-room'); + }); + + it('navigates to goodbye when publishingError is set and user is online', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + + renderHook(() => useMeetingRoom(), { + publisherContext: { + initialValue: { + publishingError: { header: 'Publisher error', caption: 'Could not publish' }, + }, + }, + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + '/goodbye/test-room', + expect.objectContaining({ + state: expect.objectContaining({ + header: 'Publisher error', + caption: 'Could not publish', + }), + }) + ); + }); + }); + + it('does not navigate to goodbye when reconnecting is true', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + + renderHook(() => useMeetingRoom(), { + publisherContext: { + initialValue: { + publishingError: { header: 'err', caption: 'desc' }, + }, + }, + sessionContext: { initialValue: { reconnecting: true } }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(mockNavigate).not.toHaveBeenCalledWith('/goodbye/test-room', expect.anything()); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; + publisherContext?: ProviderOptions['PublisherContext']; +}; + +function renderHook( + render: () => Result, + { userContext, sessionContext, publisherContext }: RenderOptions = {} +) { + const { wrapper: ProvidersWrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.publisher, providers.runtime], + { + userContext: { + value: { defaultSettings: { name: 'Test User' } }, + ...userContext, + }, + sessionContext, + publisherContext, + runtimeContext: { videoClient: mockVideoClient as unknown as VideoClient }, + } + ); + + const RouterWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + return { + ...context, + ...renderHookBase(render, { wrapper: RouterWrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/usePushToTalk.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/usePushToTalk.spec.tsx new file mode 100644 index 00000000..558f0a28 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/usePushToTalk.spec.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, act } from '@testing-library/react'; +import usePushToTalk from '../usePushToTalk'; + +type FixtureProps = { enabled: boolean; onToggle: () => void; initialAudioEnabled?: boolean }; + +const PushToTalkFixture = ({ enabled, onToggle, initialAudioEnabled = false }: FixtureProps) => { + const [isAudioEnabled, setIsAudioEnabled] = useState(initialAudioEnabled); + + const toggleAudio = () => { + onToggle(); + setIsAudioEnabled((prev) => !prev); + }; + + usePushToTalk({ enabled, isAudioEnabled, toggleAudio }); + return null; +}; + +describe('usePushToTalk', () => { + it('does nothing when disabled', () => { + const onToggle = vi.fn(); + render(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + globalThis.dispatchEvent( + new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + + expect(onToggle).not.toHaveBeenCalled(); + }); + + it('unmutes on Space down and mutes on Space up when enabled', () => { + const onToggle = vi.fn(); + render(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + + it('ignores auto-repeat keydown while Space is held', () => { + const onToggle = vi.fn(); + render(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true, repeat: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + + it('ignores Space events originating from text-entry elements', () => { + const onToggle = vi.fn(); + render(); + + const input = document.createElement('input'); + document.body.appendChild(input); + + try { + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + input.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true })); + }); + + expect(onToggle).not.toHaveBeenCalled(); + } finally { + input.remove(); + } + }); + + it('does not mute on keyup if mic was already unmuted before Space press', () => { + const onToggle = vi.fn(); + render(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(0); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(0); + }); + + it('resets state when disabled while holding Space', () => { + const onToggle = vi.fn(); + const { rerender } = render(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + + act(() => { + globalThis.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }) + ); + }); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useReceivingCaptions.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useReceivingCaptions.spec.tsx new file mode 100644 index 00000000..bd5590c3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useReceivingCaptions.spec.tsx @@ -0,0 +1,84 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Subscriber } from '@vonage/client-sdk-video'; +import { EventEmitter } from 'events'; +import useReceivingCaptions from '../useReceivingCaptions'; +import { SubscriberWrapper } from '../../types/session'; + +type TestSubscriber = Subscriber & EventEmitter; + +describe('useReceivingCaptions', () => { + const createSubscriberWrapper = (id: string): SubscriberWrapper => { + const mockSubscriber = Object.assign(new EventEmitter(), { + id, + on: vi.fn((event, handler) => { + EventEmitter.prototype.on.call(mockSubscriber, event, handler); + }), + off: vi.fn((event, handler) => { + EventEmitter.prototype.off.call(mockSubscriber, event, handler); + }), + videoWidth: () => 1280, + videoHeight: () => 720, + subscribeToVideo: () => {}, + stream: { + streamId: id, + name: 'Test User', + }, + }) as unknown as TestSubscriber; + + return { + id, + element: document.createElement('video'), + isScreenshare: false, + isPinned: false, + subscriber: mockSubscriber, + }; + }; + + it('should return initial empty values when no subscriber is provided', () => { + const { result } = renderHook(() => useReceivingCaptions({ subscriber: undefined })); + + expect(result.current).toEqual({ + caption: '', + isReceivingCaptions: false, + }); + }); + + it('should subscribe to captionReceived events when there is a subscriber', () => { + const mockSubscriber = createSubscriberWrapper('subscriber-1').subscriber; + renderHook(() => useReceivingCaptions({ subscriber: mockSubscriber })); + + expect(mockSubscriber.on).toHaveBeenCalledTimes(1); + expect(mockSubscriber.on).toHaveBeenCalledWith('captionReceived', expect.any(Function)); + }); + + it('should unsubscribe from captionReceived events when the hook gets unmounted', () => { + const mockSubscriber = createSubscriberWrapper('subscriber-1').subscriber; + const { unmount } = renderHook(() => useReceivingCaptions({ subscriber: mockSubscriber })); + + unmount(); + + expect(mockSubscriber.off).toHaveBeenCalledTimes(1); + expect(mockSubscriber.off).toHaveBeenCalledWith('captionReceived', expect.any(Function)); + }); + + it('should update caption and isReceivingCaptions on captionReceived event', () => { + const mockSubscriber = createSubscriberWrapper('subscriber-1').subscriber; + + const { result } = renderHook(() => useReceivingCaptions({ subscriber: mockSubscriber })); + + expect(result.current.caption).toBe(''); + expect(result.current.isReceivingCaptions).toBe(false); + + act(() => { + (mockSubscriber as unknown as EventEmitter).emit('captionReceived', { + streamId: 'subscriber-1', + caption: 'Test Caption', + isFinal: true, + }); + }); + + expect(result.current.caption).toBe('Test Caption'); + expect(result.current.isReceivingCaptions).toBe(true); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRightPannel.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRightPannel.spec.tsx new file mode 100644 index 00000000..1f3df3aa --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRightPannel.spec.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import useRightPanel from '../useRightPanel'; + +describe('useRightPanel hook', () => { + it('should initialize with activeTab "closed" and unreadCount 0', () => { + const { result } = renderHook(() => useRightPanel()); + + expect(result.current.rightPanelState.activeTab).toBe('closed'); + expect(result.current.rightPanelState.unreadCount).toBe(0); + }); + + it('should toggle participant list visibility', () => { + const { result } = renderHook(() => useRightPanel()); + + act(() => { + result.current.toggleParticipantList(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('participant-list'); + + act(() => { + result.current.toggleParticipantList(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('closed'); + }); + + it('should toggle chat visibility and reset unreadCount', () => { + const { result } = renderHook(() => useRightPanel()); + + act(() => { + result.current.toggleChat(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('chat'); + expect(result.current.rightPanelState.unreadCount).toBe(0); + + act(() => { + result.current.incrementUnreadCount(); + }); + + expect(result.current.rightPanelState.unreadCount).toBe(0); + }); + + it('should increment unreadCount when activeTab is not "chat"', () => { + const { result } = renderHook(() => useRightPanel()); + + act(() => { + result.current.incrementUnreadCount(); + }); + + expect(result.current.rightPanelState.unreadCount).toBe(1); + + act(() => { + result.current.incrementUnreadCount(); + }); + + expect(result.current.rightPanelState.unreadCount).toBe(2); + }); + + it('should close the right panel', () => { + const { result } = renderHook(() => useRightPanel()); + + act(() => { + result.current.toggleChat(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('chat'); + + act(() => { + result.current.closeRightPanel(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('closed'); + }); + + it('should toggle report issue tab visibility', () => { + const { result } = renderHook(() => useRightPanel()); + + act(() => { + result.current.toggleReportIssue(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('issues'); + + act(() => { + result.current.toggleReportIssue(); + }); + + expect(result.current.rightPanelState.activeTab).toBe('closed'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRoomShareUrl.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRoomShareUrl.spec.tsx new file mode 100644 index 00000000..8dd552a9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useRoomShareUrl.spec.tsx @@ -0,0 +1,29 @@ +import { renderHook } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import useRoomShareUrl from '../useRoomShareUrl'; +import { makeTestProvider, providers } from '@test/providers'; + +// A valid fake JWT containing sessionId: '1_MX4xMjM0NTY3OH4-VGh1IEZlYiAyNyAwODozMjozNCBQU1QgMjAyMH4wLjI0NDYxMjE' +const mockSessionKey = + 'eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOiIxX01YNHhNak0wTlRZM09INC1WR2gxSUVabFlpQXlOeUF3T0Rvek1qb3pOQ0JRVTFRZ01qQXlNSDR3TGpJME5EWXhNakUiLCJyb29tTmFtZSI6IlRlc3RDb21wb25lbnRSb29tIn0.fakesig'; + +describe('useRoomShareUrl', () => { + beforeAll(() => { + vi.spyOn(window, 'location', 'get').mockReturnValue({ + origin: 'https://example.com', + } as unknown as Location); + }); + + it('should return link to waiting room', () => { + const { wrapper } = makeTestProvider([providers.user, providers.session, providers.runtime], { + sessionContext: { + initialValue: { sessionKey: mockSessionKey }, + }, + userContext: undefined, + runtimeContext: undefined, + }); + + const { result } = renderHook(() => useRoomShareUrl(), { wrapper }); + expect(result.current).toBe(`https://example.com/waiting-room/${mockSessionKey}`); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useScreenShare.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useScreenShare.spec.tsx new file mode 100644 index 00000000..b5a24a84 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useScreenShare.spec.tsx @@ -0,0 +1,314 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { renderHook as renderHookBase, act } from '@testing-library/react'; +import { Publisher, initPublisher } from '@vonage/client-sdk-video'; +import useScreenShare from '../useScreenShare'; +import { makeTestProvider, providers, ProviderOptions } from '@test/providers'; +import EventEmitter from 'events'; +import type VonageVideoClient from '../../utils/VonageVideoClient'; +import { type UserContextType } from '../../Context/user'; + +// Mocking dependencies +vi.mock('@vonage/client-sdk-video', () => ({ + initPublisher: vi.fn(), +})); + +describe('useScreenSharing', () => { + let mockVonageVideoClient: Partial; + let mockPublisher: Partial; + let handlers: Record void>; + const mockPublish = vi.fn(); + const mockUnpublish = vi.fn(); + + beforeEach(() => { + handlers = {}; + mockVonageVideoClient = Object.assign(new EventEmitter(), { + on: vi.fn(), + off: vi.fn(), + }) as unknown as Partial as VonageVideoClient; + + mockPublisher = { + on: vi.fn((event, cb) => { + handlers[event] = cb; + }), + destroy: vi.fn(), + } as unknown as Partial; + + (initPublisher as ReturnType).mockReturnValue(mockPublisher as Publisher); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes screen sharing publisher and publishes', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + // toggling screen share on + await result.current.toggleShareScreen(); + }); + + expect(initPublisher).toHaveBeenCalledWith( + undefined, + { + videoSource: 'screen', + insertDefaultUI: false, + videoContentHint: 'detail', + name: "TestUser's screen", + }, + expect.any(Function) + ); + expect(mockPublisher.on).toHaveBeenCalledWith('streamCreated', expect.any(Function)); + expect(mockPublisher.on).toHaveBeenCalledWith('streamDestroyed', expect.any(Function)); + expect(mockPublisher.on).toHaveBeenCalledWith('mediaStopped', expect.any(Function)); + }); + + it('unpublishes screen sharing when already sharing', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + // toggling screen share on + await result.current.toggleShareScreen(); + // toggling screen share off + await result.current.toggleShareScreen(); + }); + + expect(result.current.isSharingScreen).toBe(false); + }); + + it('sets isEntireScreen to true when displaySurface is monitor', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + await result.current.toggleShareScreen(); + }); + + const mockVideoEl = { + srcObject: { + getVideoTracks: () => [{ getSettings: () => ({ displaySurface: 'monitor' }) }], + }, + } as unknown as HTMLVideoElement; + + act(() => { + handlers['videoElementCreated']({ element: mockVideoEl }); + }); + + expect(result.current.isEntireScreen).toBe(true); + expect(result.current.screenshareVideoElement).toBe(mockVideoEl); + }); + + it('sets isEntireScreen to false when displaySurface is window', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + await result.current.toggleShareScreen(); + }); + + const mockVideoEl = { + srcObject: { + getVideoTracks: () => [{ getSettings: () => ({ displaySurface: 'window' }) }], + }, + } as unknown as HTMLVideoElement; + + act(() => { + handlers['videoElementCreated']({ element: mockVideoEl }); + }); + + expect(result.current.isEntireScreen).toBe(false); + }); + + it('resets isEntireScreen when streamDestroyed fires', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + await result.current.toggleShareScreen(); + }); + + const mockVideoEl = { + srcObject: { + getVideoTracks: () => [{ getSettings: () => ({ displaySurface: 'monitor' }) }], + }, + } as unknown as HTMLVideoElement; + + act(() => { + handlers['videoElementCreated']({ element: mockVideoEl }); + }); + + expect(result.current.screenshareVideoElement).toBe(mockVideoEl); + expect(result.current.isEntireScreen).toBe(true); + + act(() => { + handlers['streamDestroyed'](); + }); + + expect(result.current.isEntireScreen).toBe(false); + expect(result.current.isSharingScreen).toBe(false); + expect(result.current.screenshareVideoElement).toBe(undefined); + }); + + it('sets isEntireScreen to true when displaySurface is undefined but dimensions match the screen area', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = mockVonageVideoClient as unknown as VonageVideoClient; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + await result.current.toggleShareScreen(); + }); + + const mockVideoEl = { + srcObject: { + getVideoTracks: () => [ + { + getSettings: () => ({ + displaySurface: undefined, + width: window.screen.width, + height: window.screen.height, + }), + }, + ], + }, + } as unknown as HTMLVideoElement; + + act(() => { + handlers['videoElementCreated']({ element: mockVideoEl }); + }); + + expect(result.current.isEntireScreen).toBe(true); + }); + + it('does not initialize publisher if session is null', async () => { + const { result } = render({ + userContext: { + __interceptor: (context: UserContextType | null) => { + context!.user.defaultSettings.name = 'TestUser'; + }, + }, + sessionContext: { + __interceptor: (context) => { + if (context) { + context.vonageVideoClient = null; + context.publish = mockPublish; + context.unpublish = mockUnpublish; + } + }, + }, + }); + + await act(async () => { + await result.current.toggleShareScreen(); + }); + + expect(initPublisher).not.toHaveBeenCalled(); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + sessionContext?: ProviderOptions['SessionContext']; +}; + +function render({ userContext, sessionContext }: RenderOptions = {}) { + const { wrapper, ...context } = makeTestProvider( + [providers.user, providers.session, providers.runtime], + { + sessionContext, + userContext, + runtimeContext: undefined, + } + ); + + return { + ...context, + ...renderHookBase(() => useScreenShare(), { + wrapper, + }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useToolbarButtons.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useToolbarButtons.spec.tsx new file mode 100644 index 00000000..1df2c01f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useToolbarButtons.spec.tsx @@ -0,0 +1,187 @@ +import { afterEach, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { RefObject } from 'react'; +import useToolbarButtons from '../useToolbarButtons'; + +vi.mock('resize-observer-polyfill', () => ({ + default: vi.fn((cb: ResizeObserverCallback) => { + return { + observe: vi.fn().mockImplementation((target: HTMLElement) => { + const entry: ResizeObserverEntry = { + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }], + contentBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }], + devicePixelContentBoxSize: [ + { inlineSize: target.clientWidth, blockSize: target.clientHeight }, + ], + }; + cb([entry], this as unknown as ResizeObserver); + }), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }), +})); + +vi.mock('../../utils/constants', () => ({ + RIGHT_PANEL_BUTTON_COUNT: 2, +})); + +describe('useToolbarButtons', () => { + let toolbarRef: RefObject; + let mediaControlsRef: RefObject; + let overflowAndExitRef: RefObject; + let rightPanelControlsRef: RefObject; + let timeRoomNameRef: RefObject; + + const numberOfToolbarButtons = 5; + + beforeEach(() => { + toolbarRef = { current: document.createElement('div') }; + if (toolbarRef.current) { + toolbarRef.current.id = 'toolbar-ref'; + } + mediaControlsRef = { current: document.createElement('div') }; + overflowAndExitRef = { current: document.createElement('div') }; + rightPanelControlsRef = { current: document.createElement('div') }; + timeRoomNameRef = { current: document.createElement('div') }; + if (timeRoomNameRef.current) { + timeRoomNameRef.current.id = 'time-room-name'; + } + + Object.defineProperty(mediaControlsRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 214, + }); + Object.defineProperty(overflowAndExitRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 108, + }); + vi.spyOn(window, 'getComputedStyle').mockImplementation((elt: Element) => { + if (elt.id === 'toolbar-ref') { + return { + paddingLeft: '30px', + paddingRight: '30px', + } as CSSStyleDeclaration; + } + if (elt.id === 'time-room-name') { + return { + marginRight: '12px', + } as CSSStyleDeclaration; + } + return { + marginLeft: '12px', + } as CSSStyleDeclaration; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + it('returns nothing when there is no space in the toolbar', () => { + Object.defineProperty(toolbarRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 412, + }); + + const { result } = renderHook(() => + useToolbarButtons({ + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, + timeRoomNameRef, + }) + ); + + const { centerButtonLimit, rightButtonLimit, displayTimeRoomName } = result.current; + expect(centerButtonLimit).toBe(0); + expect(rightButtonLimit).toBe(0); + expect(displayTimeRoomName).toBe(false); + }); + + it('returns only the first button when there is space for one button in the toolbar', () => { + Object.defineProperty(toolbarRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 466, + }); + + const { result } = renderHook(() => + useToolbarButtons({ + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, + timeRoomNameRef, + }) + ); + + const { centerButtonLimit, rightButtonLimit, displayTimeRoomName } = result.current; + + expect(centerButtonLimit).toBe(1); + expect(rightButtonLimit).toBe(1); + expect(displayTimeRoomName).toBe(false); + }); + + it('returns only the first three button when there is space for three button in the toolbar', () => { + Object.defineProperty(toolbarRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 586, + }); + + const { result } = renderHook(() => + useToolbarButtons({ + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, + timeRoomNameRef, + }) + ); + + const { centerButtonLimit, rightButtonLimit, displayTimeRoomName } = result.current; + + expect(centerButtonLimit).toBe(3); + expect(rightButtonLimit).toBe(3); + expect(displayTimeRoomName).toBe(false); + }); + + it('displays the time and room name as well as all of the buttons', () => { + Object.defineProperty(toolbarRef.current, 'clientWidth', { + configurable: true, + writable: true, + value: 826, + }); + + const { result } = renderHook(() => + useToolbarButtons({ + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, + timeRoomNameRef, + }) + ); + + const { centerButtonLimit, rightButtonLimit, displayTimeRoomName } = result.current; + + expect(centerButtonLimit).toBe(3); + expect(rightButtonLimit).toBe(5); + expect(displayTimeRoomName).toBe(true); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWaitingRoom.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWaitingRoom.spec.tsx new file mode 100644 index 00000000..0ec1f3dc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWaitingRoom.spec.tsx @@ -0,0 +1,216 @@ +import { act, renderHook as renderHookBase } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import useWaitingRoom from '../useWaitingRoom'; +import { makeTestProvider, ProviderOptions, providers } from '@test/providers'; +import { DEVICE_ACCESS_STATUS } from '@utils/constants'; +import { env } from '../../env'; +import { setupWindowNavigatorMock } from '@web-test/fixtures'; + +vi.mock('@vonage/client-sdk-video'); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ roomIdentifier: 'test-room' }), + }; +}); + +vi.mock('../useBackgroundPublisherContext', () => ({ + default: () => ({ + initBackgroundLocalPublisher: vi.fn(), + publisher: null, + }), +})); + +describe('useWaitingRoom', () => { + beforeEach(() => { + vi.clearAllMocks(); + env.WAITING_ROOM_ALLOW_DEVICE_SELECTION = true; + + setupWindowNavigatorMock({ + mediaDevices: { + addEventListener: vi.fn(), + enumerateDevices: Promise.resolve([]), + }, + }); + + vi.spyOn(globalThis.navigator.permissions, 'query').mockResolvedValue({ + state: 'granted', + } as PermissionStatus); + }); + + it('returns all expected fields', () => { + const { result } = renderHook(() => useWaitingRoom()); + + expect(result.current).toMatchObject({ + anchorEl: null, + openAudioInput: false, + openVideoInput: false, + openAudioOutput: false, + username: expect.any(String), + setUsername: expect.any(Function), + accessStatus: DEVICE_ACCESS_STATUS.ACCEPTED, + isRoomReady: true, + handleAudioInputOpen: expect.any(Function), + handleVideoInputOpen: expect.any(Function), + handleAudioOutputOpen: expect.any(Function), + handleClose: expect.any(Function), + }); + }); + + it('isRoomReady is false when isVideoLoading is true', () => { + const { result } = renderHook(() => useWaitingRoom(), { + previewPublisherContext: { + __interceptor: (ctx) => { + if (ctx) ctx.isVideoLoading = true; + }, + }, + }); + expect(result.current.isRoomReady).toBe(false); + }); + + it('isRoomReady is false when accessStatus is not ACCEPTED', () => { + const { result } = renderHook(() => useWaitingRoom(), { + previewPublisherContext: { + __interceptor: (ctx) => { + if (ctx) ctx.accessStatus = DEVICE_ACCESS_STATUS.PENDING; + }, + }, + }); + + expect(result.current.isRoomReady).toBe(false); + }); + + it('isRoomReady is false when WAITING_ROOM_ALLOW_DEVICE_SELECTION is false', () => { + env.WAITING_ROOM_ALLOW_DEVICE_SELECTION = false; + + const { result } = renderHook(() => useWaitingRoom()); + expect(result.current.isRoomReady).toBe(false); + }); + + describe('audio input menu', () => { + it('handleAudioInputOpen sets anchorEl and opens the menu', () => { + const { result } = renderHook(() => useWaitingRoom()); + const fakeButton = document.createElement('button'); + + act(() => { + result.current.handleAudioInputOpen({ + currentTarget: fakeButton, + } as unknown as React.MouseEvent); + }); + + expect(result.current.openAudioInput).toBe(true); + expect(result.current.anchorEl).toBe(fakeButton); + }); + + it('handleClose closes the audio input menu', () => { + const { result } = renderHook(() => useWaitingRoom()); + const fakeButton = document.createElement('button'); + + act(() => { + result.current.handleAudioInputOpen({ + currentTarget: fakeButton, + } as unknown as React.MouseEvent); + }); + + act(() => { + result.current.handleClose(); + }); + + expect(result.current.openAudioInput).toBe(false); + expect(result.current.anchorEl).toBeNull(); + }); + }); + + describe('video input menu', () => { + it('handleVideoInputOpen sets anchorEl and opens the menu', () => { + const { result } = renderHook(() => useWaitingRoom()); + const fakeButton = document.createElement('button'); + + act(() => { + result.current.handleVideoInputOpen({ + currentTarget: fakeButton, + } as unknown as React.MouseEvent); + }); + + expect(result.current.openVideoInput).toBe(true); + expect(result.current.anchorEl).toBe(fakeButton); + }); + }); + + describe('audio output menu', () => { + it('handleAudioOutputOpen sets anchorEl and opens the menu', () => { + const { result } = renderHook(() => useWaitingRoom()); + const fakeButton = document.createElement('button'); + + act(() => { + result.current.handleAudioOutputOpen({ + currentTarget: fakeButton, + } as unknown as React.MouseEvent); + }); + + expect(result.current.openAudioOutput).toBe(true); + expect(result.current.anchorEl).toBe(fakeButton); + }); + }); + + it('handleClose resets all menus and anchorEl', () => { + const { result } = renderHook(() => useWaitingRoom()); + const fakeButton = document.createElement('button'); + + act(() => { + result.current.handleAudioOutputOpen({ + currentTarget: fakeButton, + } as unknown as React.MouseEvent); + }); + + act(() => { + result.current.handleClose(); + }); + + expect(result.current.anchorEl).toBeNull(); + expect(result.current.openAudioInput).toBe(false); + expect(result.current.openAudioOutput).toBe(false); + expect(result.current.openVideoInput).toBe(false); + }); + + it('setUsername updates the username value', () => { + const { result } = renderHook(() => useWaitingRoom()); + + act(() => { + result.current.setUsername('New Name'); + }); + + expect(result.current.username).toBe('New Name'); + }); +}); + +type RenderOptions = { + userContext?: ProviderOptions['UserContext']; + previewPublisherContext?: ProviderOptions['PreviewPublisherContext']; +}; + +function renderHook( + render: () => Result, + { userContext, previewPublisherContext }: RenderOptions = {} +) { + const { wrapper, ...context } = makeTestProvider([providers.user, providers.previewPublisher], { + userContext, + previewPublisherContext: { + ...previewPublisherContext, + __interceptor: (ctx) => { + if (ctx) { + ctx.accessStatus = DEVICE_ACCESS_STATUS.ACCEPTED; + ctx.isVideoLoading = false; + } + previewPublisherContext?.__interceptor?.(ctx); + }, + }, + }); + + return { + ...context, + ...renderHookBase(render, { wrapper }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWindowWidth.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWindowWidth.spec.tsx new file mode 100644 index 00000000..cb32b4f0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/tests/useWindowWidth.spec.tsx @@ -0,0 +1,41 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useWindowWidth from '../useWindowWidth'; + +describe('useWindowWidth', () => { + const nativeWindowInnerWidth = window.innerWidth; + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: nativeWindowInnerWidth, + }); + }); + + it('should return the initial state', () => { + const { result } = renderHook(() => useWindowWidth()); + + expect(result.current).toBe(1024); + }); + + it('should update value on window resize', () => { + const newWidth = 768; + const { result } = renderHook(() => useWindowWidth()); + + act(() => { + window.innerWidth = newWidth; + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current).toBe(newWidth); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useActiveSpeaker.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useActiveSpeaker.tsx new file mode 100644 index 00000000..275fbca8 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useActiveSpeaker.tsx @@ -0,0 +1,12 @@ +import useSessionContext from './useSessionContext'; + +/** + * Returns the subscriber ID for the current active speaker, or undefined if no participant is actively speaking. + * @returns {string | undefined} Returns the active speaker ID or undefined if there is no active speaker. + */ +const useActiveSpeaker = (): string | undefined => { + const { activeSpeakerId } = useSessionContext(); + return activeSpeakerId; +}; + +export default useActiveSpeaker; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useArchives.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useArchives.tsx new file mode 100644 index 00000000..35f9f889 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useArchives.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useArchives as useArchivesBase } from '@core/hooks'; +import { createArchiveFromServer, ServerArchive } from '../api/archiving/model'; +import { SingleArchiveResponse } from '@vonage/video'; + +export type UseArchivesProps = { + sessionKey: string | undefined; +}; + +const pollingIntervalMs = 5000; + +/** + * Returns the archives from a session, or `error` if there is an error. + * @param { UseArchivesProps} props - The props for the hook. + * @returns {Archive[] | 'error'} An array of Archives, or the text, `error`. + */ +const useArchives = ({ sessionKey }: UseArchivesProps) => { + const { data, ...rest } = useArchivesBase({ + sessionKey, + queryOptions: { + enabled: !!sessionKey, + refetchInterval: (query) => { + const archives = query.state.data?.items; + + if (!archives || !hasPending(archives)) { + return false; + } + + return pollingIntervalMs; + }, + }, + }); + + const { i18n } = useTranslation(); + + return { + ...rest, + data: useMemo(() => { + if (!data) return undefined; + + return { + ...data, + items: data.items.map((archive) => { + return createArchiveFromServer(i18n.language, archive as ServerArchive); + }), + }; + }, [data, i18n.language]), + }; +}; + +function hasPending(archives: T[]): boolean { + return archives.some((archive) => !['available', 'failed'].includes(archive.status)); +} + +export default useArchives; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useAudioLevels.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useAudioLevels.tsx new file mode 100644 index 00000000..7e385522 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useAudioLevels.tsx @@ -0,0 +1,68 @@ +import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; +import throttle from '@common/execution/throttle'; +import usePublisherContext from './usePublisherContext'; +import createMovingAvgAudioLevelTracker from '../utils/movingAverageAudioLevelTracker'; // Adjust import as needed + +/** + * Creates a function to handle audio level updates using a moving average tracker. + * @param {Dispatch>} setAudioLevel - Function to update the audio level state. + * @returns {(params: { audioLevel: number }) => void} A handler function that processes and sets the audio level. + */ +const createOnHandleAudioLevelUpdated = (setAudioLevel: Dispatch>) => { + // Initialize moving average audio level tracker + const getMovingAverageAudioLevel = createMovingAvgAudioLevelTracker(); + + // Throttled function to handle audio level updates + const throttledUpdateAudioLevel = throttle( + (audioLevel: number) => { + const { logMovingAvg } = getMovingAverageAudioLevel(audioLevel); + setAudioLevel(logMovingAvg * 100); // Set the audio level state + }, + 100, + { leading: true, trailing: true } + ); + + /** + * Handles the raw audio level and updates it using the throttled function. + * @param {number} audioLevel - The raw audio level to process. + */ + const handleAudioLevel = (audioLevel: number) => { + throttledUpdateAudioLevel(audioLevel); + }; + + return ({ audioLevel }: { audioLevel: number }) => { + handleAudioLevel(audioLevel); + }; +}; + +/** + * Custom hook to track and return the current audio level of the publisher. + * + * This hook listens to audio level updates from the publisher and processes them + * using a moving average to smooth out fluctuations. It returns the current audio level as a percentage. + * @returns {number} The current audio level as a percentage (0-100). + */ +const useAudioLevels = () => { + const { publisher, isPublishing } = usePublisherContext(); + const [audioLevel, setAudioLevel] = useState(0); + + const audioLevelUpdateHandler = useMemo( + () => createOnHandleAudioLevelUpdated(setAudioLevel), + [setAudioLevel] + ); + + useEffect(() => { + if (isPublishing && publisher) { + publisher.on('audioLevelUpdated', audioLevelUpdateHandler); + } + return () => { + if (isPublishing && publisher) { + publisher.off('audioLevelUpdated', audioLevelUpdateHandler); + } + }; + }, [isPublishing, publisher, audioLevelUpdateHandler]); + + return audioLevel; +}; + +export default useAudioLevels; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useBackgroundPublisherContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useBackgroundPublisherContext.tsx new file mode 100644 index 00000000..151cb737 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useBackgroundPublisherContext.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { + BackgroundPublisherContext, + BackgroundPublisherContextType, +} from '../Context/BackgroundPublisherProvider'; + +/** + * React hook to access the background replacement publisher context containing selected publisher options. + * @returns {BackgroundPublisherContextType} - The current context value for the Background replacement Publisher Context. + */ +const useBackgroundPublisherContext = (): BackgroundPublisherContextType => { + const context = useContext(BackgroundPublisherContext); + return context; +}; + +export default useBackgroundPublisherContext; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useChat.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useChat.tsx new file mode 100644 index 00000000..92b85072 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useChat.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import useUserContext from './useUserContext'; +import { ChatMessageType } from '../types/chat'; +import { SignalType } from '../types/session'; + +export type UseChatProps = { + signal: ((data: SignalType) => void) | undefined; +}; + +export type UseChat = { + messages: ChatMessageType[]; + onChatMessage: (data: string) => void; + sendChatMessage: (text: string) => void; +}; +/** + * React hook to store ChatMessage array in state and provider functions for sending and receiving chat messages + * @param {UseChatProps} props - props for the hook + * @property {((data: SignalType) => void) | undefined} signal - function to send signal to all participants + * @returns {UseChat} return object + * @property {ChatMessageType[]} messages - array of chat messages + * @property {(data: string) => void} onChatMessage - new message handler + * @property {(text: string) => void} sendChatMessage - function to send message + */ +const useChat = ({ signal }: UseChatProps): UseChat => { + const { t } = useTranslation(); + const [messages, setMessages] = useState([]); + const { + user: { + defaultSettings: { name: localParticipantName }, + }, + } = useUserContext(); + + const sendChatMessage = useCallback( + (text: string) => { + if (!signal) { + return; + } + + signal({ + type: 'chat', + data: JSON.stringify({ + participantName: localParticipantName, + text, + }), + }); + }, + [signal, localParticipantName] + ); + + const onChatMessage = useCallback( + (data: string) => { + if (data) { + try { + const { text, participantName } = JSON.parse(data); + const message: ChatMessageType = { + timestamp: Date.now(), + participantName: participantName || t('user.unknown'), + message: text, + }; + setMessages((prev) => [...prev, message]); + } catch (err) { + console.log(err); + } + } + }, + [t] + ); + + return { + messages, + onChatMessage, + sendChatMessage, + }; +}; + +export default useChat; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useCollectBrowserInformation.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useCollectBrowserInformation.tsx new file mode 100644 index 00000000..1d3c0321 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useCollectBrowserInformation.tsx @@ -0,0 +1,44 @@ +import useSessionContext from './useSessionContext'; + +export type BrowserInformationType = { + sessionId?: string; + browser: string; + screenResolution: string; + referrer: string; + currentUrl: string; + language: string; + isOnline: boolean; + timeZone: string; + cookiesEnabled: boolean; + windowSize: { + height: number; + width: number; + }; + connectionId?: string; +}; + +/** + * Collects browser and session data from the user. + * @returns {BrowserInformationType} The session and browser information from the user. + */ +const useCollectBrowserInformation = (): BrowserInformationType => { + const { vonageVideoClient } = useSessionContext(); + return { + sessionId: vonageVideoClient?.sessionId, + browser: navigator.userAgent, + screenResolution: `${window.screen.width}x${window.screen.height}`, + referrer: document.referrer || 'No referrer', + currentUrl: window.location.href, + language: navigator.language, + isOnline: navigator.onLine, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + cookiesEnabled: navigator.cookieEnabled, + windowSize: { + width: window.innerWidth, + height: window.innerHeight, + }, + connectionId: vonageVideoClient?.connectionId, + }; +}; + +export default useCollectBrowserInformation; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDateTime.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDateTime.tsx new file mode 100644 index 00000000..03293037 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDateTime.tsx @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getFormattedDate, getFormattedTime } from '../utils/dateTime'; +/** + * @typedef {object} UseDateTimeType + * @property {string} date - The condensed date (e.g., "Wed, Jun 26") + * @property {string} time - The (standard) time ("6:29 PM") + */ + +/** + * Hook for getting the date and time at the moment. The time is updated every second. + * @returns {UseDateTimeType} the date and time + */ +const useDateTime = () => { + const { i18n } = useTranslation(); + const [date, setDate] = useState('Wed, Jun 26'); + const [time, setTime] = useState('5:01 PM'); + /** + * Gets the current time and sets it in the format of "6:29 PM". + */ + const updateTime = useCallback(() => { + const formattedTime = getFormattedTime(i18n.language); + setTime(formattedTime); + }, [i18n.language]); + + /** + * Gets the current date and sets it in the format of "Wed, Jun 26". + */ + const updateDate = useCallback(() => { + const formattedDate = getFormattedDate(i18n.language); + setDate(formattedDate); + }, [i18n.language]); + + /** + * Re-sets the time every second. + */ + const changeTime = useCallback(() => { + const interval = setInterval(updateTime, 1_000); + return () => clearInterval(interval); + }, [updateTime]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + updateTime(); + updateDate(); + return changeTime(); + }, [changeTime, updateTime, updateDate]); + + return { + date, + time, + }; +}; + +export default useDateTime; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDecodedSessionKey.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDecodedSessionKey.ts new file mode 100644 index 00000000..36b5969b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDecodedSessionKey.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { decodeSessionKey } from '@common/helpers'; +import type { Prettify, VideoSessionDetails } from '@common/types'; + +type DecodedSessionKey = Prettify< + VideoSessionDetails & { + roomName: string; + } +>; + +/** + * Custom hook to decode the sessionKey + */ +const useDecodedSessionKey = ({ + sessionKey, + sessionKeyStatus, +}: { + sessionKey: string; + sessionKeyStatus: 'valid' | 'invalid' | 'legacy_hack'; +}): DecodedSessionKey => { + return useMemo(() => { + if (sessionKeyStatus === 'invalid') throw new Error(`Invalid sessionKey: ${sessionKey}`); + if (sessionKeyStatus === 'valid') return decodeSessionKey({ sessionKey }) as DecodedSessionKey; + + // [TODO]: We'll be removing this after updating the other apps. + // The intention of this changes is to keep the current and future refactor atomic as possible. + return { + sessionKey, + roomName: sessionKey, + } as DecodedSessionKey; + }, [sessionKey, sessionKeyStatus]); +}; + +export default useDecodedSessionKey; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDropdownResizeObserver.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDropdownResizeObserver.tsx new file mode 100644 index 00000000..0cb6539b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useDropdownResizeObserver.tsx @@ -0,0 +1,37 @@ +import { useEffect, Dispatch, SetStateAction } from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; + +export type UseDropdownResizeObserverProps = { + setIsOpen: Dispatch>; + dropDownRefElement: HTMLElement | null; +}; + +/** + * Custom hook that observes the window height changes and closes the dropdown + * if the height falls below a certain threshold. + * @param {UseDropdownResizeObserverProps} props - The props for the hook. + * @property {Dispatch>} setIsOpen - Sets the state for the component. + * @property {HTMLElement | null} dropDownRefElement - The HTML element used to compare the height to the window height. + */ +const useDropdownResizeObserver = ({ + setIsOpen, + dropDownRefElement, +}: UseDropdownResizeObserverProps) => { + useEffect(() => { + const observer = new ResizeObserver(() => { + const dropDownHeight = dropDownRefElement?.offsetHeight ?? 0; + + // The 15 multiplier accounts for cases where the measured height is lower + // due to CSS transforms, dynamic rendering, etc. + if (window.innerHeight < dropDownHeight * 15) { + setIsOpen(false); + } + }); + + observer.observe(document.documentElement); + + return () => observer.disconnect(); + }, [setIsOpen, dropDownRefElement]); +}; + +export default useDropdownResizeObserver; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useElementDimensions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useElementDimensions.tsx new file mode 100644 index 00000000..be8cc8f2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useElementDimensions.tsx @@ -0,0 +1,42 @@ +import { Dimensions } from '@vonage/client-sdk-video'; +import throttle from '@common/execution/throttle'; +import { RefObject, useEffect, useRef, useState } from 'react'; + +export type UseElementDimensionsProps = { + elementRef: RefObject; +}; +/** + * Util hook to observe element resize changes and get element dimensions as react state. + * State changes are throttled for performance + * @param {UseElementDimensionsProps} props - the props for this hook + * @property {RefObject} elementRef - HTMLElement ref object + * @returns {Dimensions} - element dimensions + */ +const useElementDimensions = ({ elementRef }: UseElementDimensionsProps): Dimensions => { + const [elementDimensions, setElementDimensions] = useState({ width: 0, height: 0 }); + const resizeObserver = useRef(undefined); + + useEffect(() => { + const elementCurrent = elementRef.current; + if (elementCurrent && !resizeObserver.current) { + const throttledSetDimensions = throttle(() => { + setElementDimensions({ + height: elementCurrent.offsetHeight, + width: elementCurrent.offsetWidth, + }); + }, 20); + resizeObserver.current = new ResizeObserver(() => { + throttledSetDimensions(); + }); + resizeObserver.current?.observe(elementCurrent); + } + return () => { + if (elementCurrent) { + resizeObserver.current?.unobserve(elementCurrent); + } + }; + }, [elementRef]); + return elementDimensions; +}; + +export default useElementDimensions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useEmoji.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useEmoji.tsx new file mode 100644 index 00000000..aaf6aeef --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useEmoji.tsx @@ -0,0 +1,130 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Connection } from '@vonage/client-sdk-video'; +import throttle from '@common/execution/throttle'; +import { EMOJI_DISPLAY_DURATION } from '../utils/constants'; +import { SignalEvent, SignalType, SubscriberWrapper } from '../types/session'; + +type EmojiDataType = { + emoji: string; + time: number; +}; + +export type UseEmojiProps = { + signal: ((data: SignalType) => void) | undefined; + getConnectionId: () => string | undefined; +}; + +export type UseEmoji = { + sendEmoji: (emoji: string) => void; + emojiQueue: EmojiWrapper[]; + onEmoji: (event: SignalEvent, subscriberWrappers: SubscriberWrapper[]) => void; +}; + +export type EmojiWrapper = { + name: string; + emoji: string; + time: number; +}; + +/** + * React hook to queue emojis into an array for display and provides functions for sending and receiving emojis. + * @param {UseEmojiProps} props - props for the hook + * @property {((data: SignalType) => void) | undefined} signal - function to send signal to all participants + * @property {() => string | undefined} getConnectionId - get the connection ID of the current user + * @returns {UseEmoji} returned object + * @property {(emoji: string) => void} sendEmoji - function to send emojis + * @property {EmojiWrapper[]} emojiQueue - emojis to display + * @property {(event: SignalEvent, subscriberWrappers: SubscriberWrapper[]) => void} onEmoji - emoji handler + */ +const useEmoji = ({ signal, getConnectionId }: UseEmojiProps): UseEmoji => { + const [emojiQueue, setEmojiQueue] = useState([]); + + /** + * Sends an emoji to all participants in the room. + * @param {string} emoji - The emoji to be sent + */ + const sendEmoji = useMemo(() => { + // We limit emojis to being sent every 500ms. + const throttledFunc = throttle( + (emoji: string) => { + if (!signal) { + return; + } + + const data = JSON.stringify({ emoji, time: new Date().getTime() }); + signal({ type: 'emoji', data }); + }, + 500, + { leading: true, trailing: false } + ); + return throttledFunc; + }, [signal]); + + /** + * Checks if the given connection belongs to the current user. + * @param {Connection} sendingConnection - The connection of a user. + * @returns {boolean} - Returns `true` if the connection is the current user's, else `false`. + */ + const isOwnConnection = useCallback( + (sendingConnection: Connection): boolean => { + return sendingConnection.connectionId === getConnectionId?.(); + }, + [getConnectionId] + ); + /** + * Retrieves the user's name or `You` if you are the sender from a given Connection. + * @param {Connection} sendingConnection - The connection object to evaluate. + * @param {SubscriberWrapper[]} subscriberWrappers - all subscriber wrappers in the session + * @returns {string} The user's name, `You`, or an empty string. + */ + const getSenderName = useCallback( + ( + sendingConnection: Connection, + subscriberWrappers: SubscriberWrapper[] + ): string | undefined => { + const isYou = isOwnConnection(sendingConnection); + if (isYou) { + return 'You'; + } + + const sendingSubscriberWrapper = subscriberWrappers.find( + (subscriberWrapper) => + subscriberWrapper.subscriber.stream?.connection.connectionId === + sendingConnection?.connectionId && !subscriberWrapper.isScreenshare + ); + return sendingSubscriberWrapper?.subscriber.stream?.name; + }, + [isOwnConnection] + ); + + /** + * Manages signals sent by users in the room. Any emojis sent by the room's users + * are processed in a data queue to be rendered in the application. + * @param {SignalEvent} signalEvent - Signal event dispatched by the session. + * @param {SubscriberWrapper[]} subscriberWrappers - all subscriber wrappers in the session + */ + const onEmoji = useCallback( + ({ data, from: sendingConnection }: SignalEvent, subscriberWrappers: SubscriberWrapper[]) => { + if (data && sendingConnection) { + const senderName = getSenderName(sendingConnection, subscriberWrappers) ?? ''; + const { emoji, time }: EmojiDataType = JSON.parse(data); + + const emojiWrapper: EmojiWrapper = { + name: senderName, + emoji, + time, + }; + setEmojiQueue((previousEmojiQueue) => [...previousEmojiQueue, emojiWrapper]); + + setTimeout(() => { + setEmojiQueue((previousEmojiQueue) => previousEmojiQueue.slice(1)); + }, EMOJI_DISPLAY_DURATION); + } + }, + [getSenderName] + ); + + return { sendEmoji, emojiQueue, onEmoji }; +}; + +export default useEmoji; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useGoodByePage.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useGoodByePage.ts new file mode 100644 index 00000000..4d79d494 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useGoodByePage.ts @@ -0,0 +1,36 @@ +import { useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import type { Archive } from '../api/archiving/model'; +import useArchives from './useArchives'; +import useSessionKeyParam from './useSessionKeyParam'; +import { isErrorLike } from '@common/assertions'; + +type UseGoodByePageResult = { + sessionKey: string | null; + archives: Archive[] | 'error'; + header: string; + caption: string; + isSelfDeclinedRecording: boolean; +}; + +const useGoodByePage = (): UseGoodByePageResult => { + const { t } = useTranslation(); + const location = useLocation(); + const { sessionKey } = useSessionKeyParam(); + + const { data, error } = useArchives({ sessionKey }); + + const header: string = location.state?.header || t('goodbye.default.header'); + const caption: string = location.state?.caption || t('goodbye.default.message'); + const isSelfDeclinedRecording: boolean = location.state?.isSelfDeclinedRecording || false; + + return { + sessionKey, + archives: data ? data.items : ((isErrorLike(error) ? error.message : 'error') as 'error'), + header, + caption, + isSelfDeclinedRecording, + }; +}; + +export default useGoodByePage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsSmallViewport.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsSmallViewport.tsx new file mode 100644 index 00000000..d1f8a45b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsSmallViewport.tsx @@ -0,0 +1,14 @@ +import useMediaQuery from '@mui/material/useMediaQuery'; +import { SMALL_VIEWPORT } from '../utils/constants'; + +/** + * useIsSmallViewport Hook + * + * A custom hook that checks if the viewport width is less than or equal to a defined small viewport width. + * @returns {boolean} True if the viewport is small, false otherwise. + */ +const useIsSmallViewport = (): boolean => { + return useMediaQuery(`(max-width:${SMALL_VIEWPORT}px)`); +}; + +export default useIsSmallViewport; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsTabletViewport.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsTabletViewport.tsx new file mode 100644 index 00000000..8a867a55 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useIsTabletViewport.tsx @@ -0,0 +1,14 @@ +import useMediaQuery from '@mui/material/useMediaQuery'; +import { TABLET_VIEWPORT } from '../utils/constants'; + +/** + * useIsTabletViewport Hook + * + * A custom hook that checks if the viewport width is less than or equal to a defined tablet viewport width. + * @returns {boolean} True if the viewport is tablet-sized, false otherwise. + */ +const useIsTabletViewport = (): boolean => { + return useMediaQuery(`(max-width:${TABLET_VIEWPORT}px)`); +}; + +export default useIsTabletViewport; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useLayoutManager.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useLayoutManager.tsx new file mode 100644 index 00000000..5f59ae6e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useLayoutManager.tsx @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import LayoutManager from '../utils/layoutManager'; + +export type GetLayout = InstanceType['getLayout']; +/** + * React hook to return a getLayout function as defined here: + * https://github.com/aullman/opentok-layout-js?tab=readme-ov-file#usage + * getLayout takes an array of Element objects and returns an array of Box objects + * which specify exact size and position of video tiles + * @returns {GetLayout} getLayout + */ +const useLayoutManager = (): GetLayout => { + return useMemo(() => { + const layoutManager = new LayoutManager(); + + return layoutManager.getLayout.bind(layoutManager); + }, []); +}; + +export default useLayoutManager; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useMeetingRoom.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useMeetingRoom.ts new file mode 100644 index 00000000..64ee8620 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useMeetingRoom.ts @@ -0,0 +1,290 @@ +import { useEffect, useState, useEffectEvent } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import usePublisherContext from './usePublisherContext'; +import useSessionContext from './useSessionContext'; +import useScreenShare from './useScreenShare'; +import useBackgroundPublisherContext from './useBackgroundPublisherContext'; +import { DEVICE_ACCESS_STATUS } from '../utils/constants'; +import type { PublishingErrorType } from '../Context/PublisherProvider/usePublisher/usePublisher'; +import useUserContext from './useUserContext'; +import { env } from '../env'; +import useMountEffect from '@web/hooks/useMountEffect'; +import { runtime$ } from '@core/stores'; +import useSessionKeyParam from './useSessionKeyParam'; + +const useMeetingRoom = () => { + const videoClient = runtime$.useVideoClient(); + + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + const { + user: { + defaultSettings: { name }, + }, + } = useUserContext(); + const { + publisher, + publish, + quality, + initializeLocalPublisher, + publishingError, + isVideoEnabled, + publisherOptions, + } = usePublisherContext(); + + const { + initBackgroundLocalPublisher, + publisher: backgroundPublisher, + accessStatus, + } = useBackgroundPublisherContext(); + + const { sessionKey, sessionKeyStatus } = useSessionKeyParam(); + + const { + joinRoom, + subscriptionError, + subscriberWrappers, + connected, + disconnect, + reconnecting, + rightPanelActiveTab, + toggleChat, + toggleParticipantList, + toggleBackgroundEffects, + closeRightPanel, + toggleReportIssue, + archiveId, + recordingAlreadyNotified, + archiveIdStartedBySelf, + } = useSessionContext(); + + const { + isSharingScreen, + isEntireScreen, + screensharingPublisher, + screenshareVideoElement, + toggleShareScreen, + } = useScreenShare(); + + const [isUserCaptionsEnabled, setIsUserCaptionsEnabled] = useState(false); + const [captionsErrorResponse, setCaptionsErrorResponse] = useState(''); + + const [latestNotifiedArchiveId, setLatestNotifiedArchiveId] = useState(null); + const handleRecordingNotified = () => { + setLatestNotifiedArchiveId(archiveId); + }; + const shouldPromptRecordingConsent = + !!archiveId && (archiveIdStartedBySelf === null || archiveId !== archiveIdStartedBySelf); + + const hasValidUsername = name && name.trim() !== ''; + const searchParams = new URLSearchParams(location.search); + const bypass = searchParams.get('bypass') === 'true' || env.BYPASS_WAITING_ROOM; + + useMountEffect(() => { + if (!hasValidUsername && !bypass) { + navigate(`/waiting-room/${sessionKey}`); + } + }); + + useEffect(() => { + if (!hasValidUsername && !bypass) return; + if (!joinRoom || !sessionKey) return; + + /** + * [TODO]: Reconcile sessionKey if necessary. This is needed for legacy support where the url contains only a room name. + */ + const resolveAndJoin = async () => { + const resolvedSessionKey = await (async () => { + if (sessionKeyStatus === 'valid') return sessionKey; + if (sessionKeyStatus === 'invalid') throw new Error('Invalid session key'); + + /** + * [TODO]: This is a temporary solution to support legacy vera functionality without depending on the old routers + * If the roomName already exists, the backend will return the existing sessionKey, otherwise it will create a new session and return its sessionKey. + */ + const session = await videoClient.createSession({ roomName: sessionKey }); + + return session.sessionKey; + })(); + + await joinRoom({ sessionKey: resolvedSessionKey }); + }; + + void resolveAndJoin(); + + return () => { + disconnect?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionKey, hasValidUsername, bypass]); + + useEffect(() => { + if (!publisherOptions) { + return; + } + + if (!publisher) { + initializeLocalPublisher(publisherOptions); + } + }, [initializeLocalPublisher, publisherOptions, publisher]); + + useEffect(() => { + if (connected && publisher && publish) { + void publish(); + } + }, [publisher, publish, connected]); + + useEffect(() => { + if (!backgroundPublisher) { + void initBackgroundLocalPublisher(); + } + }, [initBackgroundLocalPublisher, backgroundPublisher]); + + useEffect(() => { + if (accessStatus === DEVICE_ACCESS_STATUS.ACCESS_CHANGED) { + window.location.reload(); + } + }, [accessStatus]); + + useRedirectOnPublisherError({ + publishingError, + reconnecting, + sessionKey, + }); + useRedirectOnSubscriberError({ + subscriberError: subscriptionError, + reconnecting, + sessionKey, + }); + + const isRecording = !!archiveId; + const captionsState = { + isUserCaptionsEnabled, + setIsUserCaptionsEnabled, + setCaptionsErrorResponse, + }; + + return { + t, + isSharingScreen, + isEntireScreen, + screensharingPublisher, + screenshareVideoElement, + toggleShareScreen, + rightPanelActiveTab, + toggleChat, + toggleParticipantList, + toggleBackgroundEffects, + closeRightPanel, + toggleReportIssue, + subscriberWrappers, + reconnecting, + quality, + isVideoEnabled, + isRecording, + isUserCaptionsEnabled, + captionsErrorResponse, + setCaptionsErrorResponse, + captionsState, + recordingAlreadyNotified, + archiveIdStartedBySelf, + archiveId, + shouldPromptRecordingConsent, + handleRecordingNotified, + latestNotifiedArchiveId, + }; +}; + +/** + * If the user is unable to publish, we redirect them to the goodbye page. + * This prevents users from subscribing to other participants in the room, and being unable to communicate with them. + * @param {PublishingErrorType | null} publishingError - The publishing error object or null if no error. + */ +function useRedirectOnPublisherError({ + publishingError, + reconnecting, + sessionKey, +}: { + publishingError: PublishingErrorType | null; + reconnecting: boolean | null; + sessionKey: string | null; +}) { + const navigate = useNavigate(); + + const maybeRedirect = useEffectEvent(() => { + if (!publishingError) { + return; + } + + const isBrowserOnline = (() => { + if (typeof navigator === 'undefined') return true; + return navigator.onLine; + })(); + + if (reconnecting === true || isBrowserOnline === false) { + return; + } + + const { header, caption } = publishingError; + + navigate(`/goodbye/${sessionKey ?? ''}`, { + state: { + header, + caption, + }, + }); + }); + + useEffect(() => { + maybeRedirect(); + }, [publishingError, reconnecting]); +} + +/** + * If the user is unable to subscribe, we redirect them to the goodbye page. + * This prevents users from subscribing to other participants in the room, and being unable to communicate with them. + * @param {Error | null} subscriberError - The subscriber error object or null if no error. + */ +function useRedirectOnSubscriberError({ + subscriberError, + reconnecting, + sessionKey, +}: { + subscriberError: Error | null; + reconnecting: boolean | null; + sessionKey: string | null; +}) { + const navigate = useNavigate(); + const { t } = useTranslation(); + + const maybeRedirect = useEffectEvent(() => { + if (!subscriberError) { + return; + } + + const isBrowserOnline = (() => { + if (typeof navigator === 'undefined') return true; + return navigator.onLine; + })(); + + if (reconnecting === true || isBrowserOnline === false) { + return; + } + + navigate(`/goodbye/${sessionKey || ''}`, { + state: { + header: t('subscribingErrors.blocked.title'), + caption: t('subscribingErrors.blocked.message'), + }, + }); + }); + + useEffect(() => { + maybeRedirect(); + }, [subscriberError, reconnecting]); +} + +export default useMeetingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePermissions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePermissions.tsx new file mode 100644 index 00000000..cbfe4e76 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePermissions.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { DEVICE_ACCESS_STATUS } from '../utils/constants'; + +export type PermissionsHookType = { + accessStatus: string | null; + setAccessStatus: (status: string) => void; +}; + +/** + * Hook for checking and managing the user's permissions for accessing the microphone and camera. + * Queries the browser for permission status and provides the current status and a function to update it. + * @returns {PermissionsHookType} The current access status and a function to manually update it. + */ +const usePermissions = (): PermissionsHookType => { + const [accessStatus, setAccessStatus] = useState(null); + + useEffect(() => { + const permissionsToQuery: PermissionName[] = [ + 'microphone', + 'camera', + ] as unknown as PermissionName[]; + + const queryPromises = permissionsToQuery.map((name) => navigator.permissions.query({ name })); + + Promise.all(queryPromises) + .then((statuses) => { + statuses.forEach((status) => { + if (status.state === 'prompt') { + // Handle pending state + setAccessStatus(DEVICE_ACCESS_STATUS.PENDING); + } + }); + }) + .catch((error) => { + if ( + error?.message?.includes( + "value of 'name' member of PermissionDescriptor) is not a valid value for enumeration PermissionName." + ) + ) { + // Ignore Firefox error + return; + } + console.error('Error querying permissions:', error); + }); + }, []); + + return { + accessStatus, + setAccessStatus, + }; +}; + +export default usePermissions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePreviewPublisherContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePreviewPublisherContext.tsx new file mode 100644 index 00000000..355a5dd3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePreviewPublisherContext.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { + PreviewPublisherContext, + PreviewPublisherContextType, +} from '../Context/PreviewPublisherProvider'; + +/** + * React hook to access the preview publisher context containing selected publisher options. + * @returns {PreviewPublisherContextType} - The current context value for the Preview Publisher Context. + */ +const usePreviewPublisherContext = (): PreviewPublisherContextType => { + const context = useContext(PreviewPublisherContext); + return context; +}; + +export default usePreviewPublisherContext; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePublisherContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePublisherContext.tsx new file mode 100644 index 00000000..61b27b02 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePublisherContext.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { PublisherContext, PublisherContextType } from '../Context/PublisherProvider'; + +/** + * React hook to access the Publisher context containing Vonage Video API publisher data and functions. + * @returns {PublisherContextType} - The current context value for the Publisher Context. + */ +const usePublisherContext = (): PublisherContextType => { + const context = useContext(PublisherContext); + return context; +}; + +export default usePublisherContext; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePushToTalk.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePushToTalk.tsx new file mode 100644 index 00000000..d3bdf241 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/usePushToTalk.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; + +export type UsePushToTalkArgs = { + enabled: boolean; + isAudioEnabled: boolean; + toggleAudio: () => void; +}; + +/** + * React hook to add push-to-talk functionality using the Space key. + * + * When enabled, pressing and holding the Space key will unmute audio, + * and releasing the Space key will mute audio again. + */ +const usePushToTalk = ({ enabled, isAudioEnabled, toggleAudio }: UsePushToTalkArgs): void => { + const didUnmuteOnSpaceRef = useRef(false); + const audioStateRef = useRef<{ isAudioEnabled: boolean; toggleAudio: () => void }>({ + isAudioEnabled, + toggleAudio, + }); + + audioStateRef.current.isAudioEnabled = isAudioEnabled; + audioStateRef.current.toggleAudio = toggleAudio; + + useEffect(() => { + if (!enabled) return; + + const isTextEntryTarget = (target: EventTarget | null): boolean => { + const node = target as HTMLElement | null; + if (!node) return false; + const tagName = node.tagName; + if (tagName === 'INPUT' || tagName === 'TEXTAREA') return true; + return node.isContentEditable === true; + }; + + const onKeyDown = (event: KeyboardEvent) => { + const isSpace = event.code === 'Space' || event.key === ' '; + if (!isSpace) return; + if (isTextEntryTarget(event.target)) return; + + event.preventDefault(); + if (event.repeat) return; + didUnmuteOnSpaceRef.current = false; + + // Only unmute on keydown if audio was muted. + if (!audioStateRef.current.isAudioEnabled) { + didUnmuteOnSpaceRef.current = true; + audioStateRef.current.toggleAudio(); + } + }; + + const onKeyUp = (event: KeyboardEvent) => { + const isSpace = event.code === 'Space' || event.key === ' '; + if (!isSpace) return; + if (isTextEntryTarget(event.target)) return; + + // Only re-mute on keyup if we unmuted on keydown. + if (didUnmuteOnSpaceRef.current) { + didUnmuteOnSpaceRef.current = false; + audioStateRef.current.toggleAudio(); + } + }; + + globalThis.addEventListener('keydown', onKeyDown); + globalThis.addEventListener('keyup', onKeyUp); + + return () => { + didUnmuteOnSpaceRef.current = false; + globalThis.removeEventListener('keydown', onKeyDown); + globalThis.removeEventListener('keyup', onKeyUp); + }; + }, [enabled]); +}; + +export default usePushToTalk; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useReceivingCaptions.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useReceivingCaptions.tsx new file mode 100644 index 00000000..8dc199a0 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useReceivingCaptions.tsx @@ -0,0 +1,63 @@ +import { Subscriber } from '@vonage/client-sdk-video'; +import { useState, useEffect, useMemo } from 'react'; + +/** + * @typedef CaptionsType + * @property {string} caption - The current caption text. + * @property {boolean} isReceivingCaptions - Indicates whether captions are currently being received + */ +export type CaptionsType = { + caption: string; + isReceivingCaptions: boolean; +}; + +/** + * @typedef ReceivingCaptionsProps + * @property {Subscriber | null} subscriber - The subscriber object from which to receive captions + */ +export type ReceivingCaptionsProps = { + subscriber?: Subscriber | null; +}; + +/** + * @typedef CaptionReceivedType + * @property {string} streamId - The ID of the stream from which the caption was received. + * @property {string} caption - The text of the caption received. + * @property {boolean} isFinal - Indicates whether the caption is final or still being processed + */ +export type CaptionReceivedType = { + streamId: string; + caption: string; + isFinal: boolean; +}; + +/** + * Hook to manage receiving captions from a speaker. + * @param {ReceivingCaptionsProps} props - The props for the hook. + * @property {Subscriber | null} subscriber - The subscriber object from which to receive captions. + * @returns {CaptionsType} - The current caption text and whether captions are being received. + */ +const useReceivingCaptions = ({ subscriber }: ReceivingCaptionsProps): CaptionsType => { + const [caption, setCaption] = useState(''); + const [isReceivingCaptions, setIsReceivingCaptions] = useState(false); + + const captionUpdateHandler = useMemo( + () => (event: CaptionReceivedType) => { + setIsReceivingCaptions(!!event.caption); + setCaption(event.caption); + }, + [] + ); + + useEffect(() => { + subscriber?.on('captionReceived', captionUpdateHandler); + + return () => { + subscriber?.off('captionReceived', captionUpdateHandler); + }; + }, [subscriber, captionUpdateHandler]); + + return { caption, isReceivingCaptions }; +}; + +export default useReceivingCaptions; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRightPanel.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRightPanel.tsx new file mode 100644 index 00000000..9a6646af --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRightPanel.tsx @@ -0,0 +1,124 @@ +import { useCallback, useState } from 'react'; + +export type RightPanelActiveTab = + | 'chat' + | 'participant-list' + | 'background-effects' + | 'closed' + | 'issues'; + +export type RightPanelState = { + activeTab: RightPanelActiveTab; + unreadCount: number; +}; + +export type UseRightPanel = { + closeRightPanel: () => void; + incrementUnreadCount: () => void; + rightPanelState: RightPanelState; + toggleChat: () => void; + toggleParticipantList: () => void; + toggleBackgroundEffects: () => void; + toggleReportIssue: () => void; +}; + +/** + * React hook containing all the state and update methods for the RightPanel component + * @returns {UseRightPanel} - collection of state and update methods + */ +const useRightPanel = (): UseRightPanel => { + const [rightPanelState, setRightPanelState] = useState({ + activeTab: 'closed', + unreadCount: 0, + }); + + /** + * toggleParticipantList - util to toggle participant list visibility. + */ + const toggleParticipantList = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab === 'participant-list') { + return { ...prev, activeTab: 'closed' }; + } + return { ...prev, activeTab: 'participant-list' }; + }); + }, []); + + /** + * toggleBackgroundEffects - util to toggle background effects visibility. + */ + const toggleBackgroundEffects = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab === 'background-effects') { + return { ...prev, activeTab: 'closed' }; + } + return { ...prev, activeTab: 'background-effects' }; + }); + }, []); + + /** + * closeRightPanel - util to close right panel. + */ + const closeRightPanel = useCallback(() => { + setRightPanelState((prev) => ({ + ...prev, + activeTab: 'closed', + })); + }, []); + + /** + * toggleChat - util to toggle chat visibility, + * it also resets unread message counter. + */ + const toggleChat = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab === 'chat') { + return { + activeTab: 'closed', + unreadCount: 0, + }; + } + return { unreadCount: 0, activeTab: 'chat' }; + }); + }, []); + + const toggleReportIssue = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab === 'issues') { + return { + ...prev, + activeTab: 'closed', + }; + } + return { ...prev, activeTab: 'issues' }; + }); + }, []); + + /** + * incrementUnreadCount - util to increment unread message badge by one, + * it does not increment counter if chat is open. + */ + const incrementUnreadCount = useCallback(() => { + setRightPanelState((prev) => { + if (prev.activeTab !== 'chat') { + return { + ...prev, + unreadCount: prev.unreadCount + 1, + }; + } + return prev; + }); + }, []); + + return { + closeRightPanel, + rightPanelState, + toggleChat, + toggleParticipantList, + toggleBackgroundEffects, + incrementUnreadCount, + toggleReportIssue, + }; +}; + +export default useRightPanel; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRoomShareUrl.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRoomShareUrl.tsx new file mode 100644 index 00000000..b3347feb --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useRoomShareUrl.tsx @@ -0,0 +1,12 @@ +import useSessionContext from './useSessionContext'; +/** + * Creates a shareable link to the waiting room for the current meeting room. + * @returns {string} - The shareable link. + */ +const useRoomShareUrl = (): string => { + const { sessionKey } = useSessionContext(); + const { origin } = window.location; + return `${origin}/waiting-room/${sessionKey}`; +}; + +export default useRoomShareUrl; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useScreenShare.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useScreenShare.tsx new file mode 100644 index 00000000..e4210569 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useScreenShare.tsx @@ -0,0 +1,149 @@ +import { useState, useRef, useCallback } from 'react'; +import { Publisher, initPublisher } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; +import useSessionContext from './useSessionContext'; +import useUserContext from './useUserContext'; + +/** + * @typedef {object} UseScreenShareType + * @property {() => void} toggleScreenShare - Function that starts and stop screen sharing + * @property {boolean} isSharingScreen - Indicates whether you are sharing your screen or not + */ +export type UseScreenShareType = { + toggleShareScreen: () => Promise; + isSharingScreen: boolean; + isEntireScreen: boolean; + screensharingPublisher: Publisher | null; + screenshareVideoElement: HTMLVideoElement | HTMLObjectElement | undefined; +}; + +/** + * Hook for toggling screen share and getting the current local screen share status (sharing / not sharing) + * @returns {UseScreenShareType} useScreenShare + */ +const useScreenShare = (): UseScreenShareType => { + const { t } = useTranslation(); + const { vonageVideoClient, unpublish, publish } = useSessionContext(); + const { user } = useUserContext(); + + // Using useRef to store the screen sharing publisher instance + const screenSharingPubRef = useRef(null); + + // State to track sharing status + const [isSharingScreen, setIsSharingScreen] = useState(false); + const [isEntireScreen, setIsEntireScreen] = useState(false); + const [screenshareVideoElement, setScreenshareVideoElement] = useState< + HTMLVideoElement | HTMLObjectElement + >(); + + const onScreenShareStopped = useCallback(() => { + setIsSharingScreen(false); + setIsEntireScreen(false); + setScreenshareVideoElement(undefined); + screenSharingPubRef.current = null; + }, []); + + const unpublishScreenshare = useCallback(() => { + if (screenSharingPubRef.current) { + unpublish(screenSharingPubRef.current); + setIsSharingScreen(false); + setIsEntireScreen(false); + } + }, [unpublish]); + + const handleStreamCreated = useCallback(() => { + unpublishScreenshare(); + }, [unpublishScreenshare]); + + // Using useCallback to memoize the function to avoid unnecessary re-renders + const toggleShareScreen = useCallback(async () => { + if (vonageVideoClient) { + if (!isSharingScreen) { + // Initializing the publisher for screen sharing + screenSharingPubRef.current = initPublisher( + undefined, + { + videoSource: 'screen', + insertDefaultUI: false, + videoContentHint: 'detail', + name: t('participants.screen', { participantName: user.defaultSettings.name }), + }, + (err) => { + if (err) { + onScreenShareStopped(); + } + } + ); + + // Adding class for screen sharing styling + screenSharingPubRef.current?.element?.classList.add('OT_big'); + + // Handling stream creation event + screenSharingPubRef.current?.on('streamCreated', () => { + setIsSharingScreen(true); + }); + + screenSharingPubRef.current?.on('videoElementCreated', (e) => { + const videoEl = e.element as HTMLVideoElement; + setScreenshareVideoElement(videoEl); + const mediaStream = videoEl.srcObject as MediaStream | null; + const track = mediaStream?.getVideoTracks?.()[0]; + const settings = track?.getSettings?.(); + const displaySurface = settings?.displaySurface; + + const width = settings?.width; + const height = settings?.height; + + const isMonitor = + displaySurface === 'monitor' || + (!displaySurface && + width !== undefined && + height !== undefined && + width * height >= window.screen.width * window.screen.height); + + setIsEntireScreen(isMonitor); + }); + + screenSharingPubRef.current?.on('streamDestroyed', () => { + onScreenShareStopped(); + }); + + // Handling media stopped event + screenSharingPubRef.current?.on('mediaStopped', () => { + onScreenShareStopped(); + }); + + // Publishing the screen sharing stream + await publish(screenSharingPubRef.current); + + vonageVideoClient?.on('screenshareStreamCreated', handleStreamCreated); + } else if (screenSharingPubRef.current) { + unpublishScreenshare(); + vonageVideoClient?.off('screenshareStreamCreated', handleStreamCreated); + } + } + }, [ + vonageVideoClient, + isSharingScreen, + user.defaultSettings.name, + handleStreamCreated, + unpublishScreenshare, + onScreenShareStopped, + publish, + t, + ]); + + return { + toggleShareScreen, + isSharingScreen, + isEntireScreen, + screenshareVideoElement, + /** + * On the first render this will return null + */ + + screensharingPublisher: screenSharingPubRef.current, + }; +}; + +export default useScreenShare; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/index.ts new file mode 100644 index 00000000..477799ab --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/index.ts @@ -0,0 +1 @@ +export { default } from './useSelectDeviceHandler'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.test.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.test.ts new file mode 100644 index 00000000..274dc43f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { ErrorCode } from '@core/errors'; +import { mediaDevices$ } from '@core/stores'; +import { t } from 'i18next'; +import { describe, expect, it, vi } from 'vitest'; +import useSelectDeviceHandler from './useSelectDeviceHandler'; + +describe('useSelectDeviceHandler', () => { + it('maps device access errors with a context-aware message', async () => { + const selectDeviceSpy = vi.spyOn(mediaDevices$.actions, 'selectDevice'); + + const { result } = renderHook(() => useSelectDeviceHandler()); + + selectDeviceSpy.mockRejectedValueOnce({ + type: ErrorCode.DeviceAccess, + message: 'Permission denied', + }); + + await expect( + result.current.handleSelectDevice({ + mediaDeviceKind: 'videoinput', + deviceId: 'camera-2', + }) + ).rejects.toMatchObject({ + type: ErrorCode.DeviceAccess, + fallbackMessage: t('devices.select.accessDenied', { + device: t('devices.video.camera.full'), + }), + }); + + expect(selectDeviceSpy).toHaveBeenCalledWith('videoinput', 'camera-2'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.ts new file mode 100644 index 00000000..62defe50 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSelectDeviceHandler/useSelectDeviceHandler.ts @@ -0,0 +1,53 @@ +import { makeApplicationErrorMapper, ErrorCode } from '@core/errors'; +import { mediaDevices$ } from '@core/stores'; +import { t } from 'i18next'; +import { useCallback } from 'react'; + +/** + * Handler for selecting a media device. Adds custom error handling for device selection errors, mapping them to user-friendly messages and logging them appropriately. + */ +const useSelectDeviceHandler = () => { + const handleSelectDevice = useCallback( + async ({ + deviceId, + mediaDeviceKind, + }: { + deviceId: string; + mediaDeviceKind: MediaDeviceKind; + }) => { + try { + await mediaDevices$.actions.selectDevice(mediaDeviceKind, deviceId); + } catch (error) { + const targetDeviceLabel = + { + audioinput: t('devices.audio.microphone.full'), + videoinput: t('devices.video.camera.full'), + audiooutput: t('devices.audio.speakers.full'), + }[mediaDeviceKind] ?? t('devices.select.unknownDevice'); + + const fallbackMessage = t('devices.select.errorContext', { device: targetDeviceLabel }); + + const applicationError = makeApplicationErrorMapper(fallbackMessage)(error); + + if (applicationError.type === ErrorCode.DeviceAccess) { + applicationError.fallbackMessage = t('devices.select.accessDenied', { + device: targetDeviceLabel, + }); + } + + if (applicationError.type === ErrorCode.DevicesTrackUnavailable) { + applicationError.fallbackMessage = t('devices.select.trackUnavailable', { + device: targetDeviceLabel, + }); + } + + throw applicationError; + } + }, + [] + ); + + return { handleSelectDevice }; +}; + +export default useSelectDeviceHandler; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionContext.tsx new file mode 100644 index 00000000..2993a5da --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionContext.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { SessionContext, SessionContextType } from '../Context/SessionProvider/session'; + +/** + * React hook to access the session context containing Vonage Video API session data and functions. + * @returns {SessionContextType} - The current context value for the Session Context. + */ +const useSessionContext = (): SessionContextType => { + const context = useContext(SessionContext); + return context; +}; + +export default useSessionContext; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionKeyParam.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionKeyParam.ts new file mode 100644 index 00000000..2bf1bb03 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSessionKeyParam.ts @@ -0,0 +1,42 @@ +import { isValidRoomName } from '@common/assertions'; +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +type SessionKeyParamResult = { + sessionKey: string; + sessionKeyStatus: 'valid' | 'invalid' | 'legacy_hack'; +}; + +/** + * Custom hook to get the sessionKey from the URL params and determine its validity. + * The sessionKey is used to identify the session/room that the user is trying to join. + * It checks if the sessionKey is a valid JWT or a valid room name (legacy support). + * @returns An object containing the sessionKey and its validity status. + */ +const useSessionKeyParam = (): SessionKeyParamResult => { + const { roomIdentifier = '' } = useParams<{ roomIdentifier: string }>(); + + return useMemo(() => { + const isJWT = isJsonWebTokenLike(roomIdentifier); + const isRoomNameValid = !isJWT && isValidRoomName(roomIdentifier); + + const sessionKeyStatus = ((): SessionKeyParamResult['sessionKeyStatus'] => { + if (isJWT) return 'valid'; + if (isRoomNameValid) return 'legacy_hack'; + + return 'invalid'; + })(); + + return { + sessionKey: roomIdentifier, + sessionKeyStatus, + }; + }, [roomIdentifier]); +}; + +function isJsonWebTokenLike(value: string): boolean { + const parts = value.split('.'); + return parts.length === 3 && parts.every(Boolean); +} + +export default useSessionKeyParam; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSpeakingDetector.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSpeakingDetector.tsx new file mode 100644 index 00000000..eef81e12 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSpeakingDetector.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect, useRef } from 'react'; +import SpeakingDetector from '../utils/SpeakingDetector'; + +export type UseSpeakingDetectorOptions = { + selectedMicrophoneId: string | undefined; + isAudioEnabled: boolean; +}; +/** + * A react hook wrapper for the SpeakingDetector util + * @param {UseSpeakingDetectorOptions} props - the props for this component + * @property {boolean} isAudioEnabled - current publisher audio state + * @property {string | undefined} selectedMicrophoneId - current publisher microphone device id + * @returns {boolean} isSpeakingWhileMuted - boolean indicating whether speaking is detected while publisher is muted + */ +const useSpeakingDetector = ({ + selectedMicrophoneId, + isAudioEnabled, +}: UseSpeakingDetectorOptions): boolean => { + const [isSpeakingWhileMuted, setIsSpeakingWhileMuted] = useState(false); + const speakingDetectorRef = useRef(null); + + useEffect(() => { + if (isAudioEnabled) { + return undefined; + } + speakingDetectorRef.current = new SpeakingDetector({ + selectedMicrophoneId, + }); + void speakingDetectorRef.current.turnSpeakingDetectorOn(); + + const handleSpeakingWhileMuted = () => { + setIsSpeakingWhileMuted(true); + }; + + const handleMuteIndicationOff = () => { + setIsSpeakingWhileMuted(false); + }; + + const speakingDetector = speakingDetectorRef.current; + + // Subscribe to events + speakingDetector.on('isSpeakingWhileMuted', handleSpeakingWhileMuted); + speakingDetector.on('isSpeakingWhileMutedOff', handleMuteIndicationOff); + + return () => { + // Cleanup and unsubscribe from events + if (speakingDetectorRef.current) { + speakingDetectorRef.current.cleanup(); + speakingDetectorRef.current.off('isSpeakingWhileMuted', handleSpeakingWhileMuted); + speakingDetectorRef.current.off('isSpeakingWhileMutedOff', handleMuteIndicationOff); + speakingDetectorRef.current = null; + setIsSpeakingWhileMuted(false); + } + }; + }, [selectedMicrophoneId, isAudioEnabled]); + + return isSpeakingWhileMuted; +}; + +export default useSpeakingDetector; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscriberTalking.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscriberTalking.tsx new file mode 100644 index 00000000..d996b0de --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscriberTalking.tsx @@ -0,0 +1,67 @@ +import { Subscriber } from '@vonage/client-sdk-video'; +import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; + +const speakingTimeThreshold = 100; +const notSpeakingTimeThreshold = 800; +const speakingVolume = 0.1; + +export type SubscriberTalkingProps = { + subscriber: Subscriber; + isActiveSpeaker: boolean; +}; + +const createOnHandleAudioLevelUpdated = (setIsTalking: Dispatch>) => { + let isTalking = false; + let timestamp = Date.now(); + + const handleAudioLevel = (audioLevel: number) => { + const now = Date.now(); + if (audioLevel > speakingVolume) { + if (!isTalking) { + isTalking = true; + timestamp = now; + } else if (now - timestamp > speakingTimeThreshold) { + isTalking = true; + timestamp = now; + setIsTalking(true); + } + } else if (isTalking && now - timestamp > notSpeakingTimeThreshold) { + isTalking = false; + setIsTalking(false); + } + }; + + return ({ audioLevel }: { audioLevel: number }) => handleAudioLevel(audioLevel); +}; + +/** + * Hook to determine whether or not the subscriber is talking. + * @param {SubscriberTalkingProps} props - The props for the hook. + * @returns {boolean} Whether the Subscriber is talking or not. + */ +const useSubscriberTalking = ({ subscriber, isActiveSpeaker }: SubscriberTalkingProps): boolean => { + const [isTalking, setIsTalking] = useState(false); + const audioLevelUpdateHandler = useMemo( + () => createOnHandleAudioLevelUpdated(setIsTalking), + [setIsTalking] + ); + + useEffect(() => { + if (subscriber && isActiveSpeaker) { + subscriber.on('audioLevelUpdated', audioLevelUpdateHandler); + } + + return () => { + if (subscriber) { + subscriber.off('audioLevelUpdated', audioLevelUpdateHandler); + } + if (isTalking) { + setIsTalking(false); + } + }; + }, [subscriber, audioLevelUpdateHandler, isActiveSpeaker, isTalking]); + + return isTalking; +}; + +export default useSubscriberTalking; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscribersInDisplayOrder.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscribersInDisplayOrder.tsx new file mode 100644 index 00000000..3da340b5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useSubscribersInDisplayOrder.tsx @@ -0,0 +1,21 @@ +import getSubscribersInDisplayOrder from '../utils/helpers/getSubscribersInDisplayOrder'; +import { SubscriberWrapper } from '../types/session'; +import { useAccumulator } from '@web/hooks'; + +/** + * Hook to get SubscriberWrappers in display order. This hook keeps the state of previous subscribers on screen + * and uses this to determine how to display subscribers such that they maintain they're position on screen whilst + * finding the optimal position to place new subscribers. + * @param {SubscriberWrapper[]} subscribersOnScreen - an array of SubscriberWrappers that should be displayed + * @returns {SubscriberWrapper[]} subscribersInDisplayOrder - SubscriberWrappers to be displayed in display order + */ +const useSubscribersInDisplayOrder = (subscribersOnScreen: SubscriberWrapper[]) => { + return useAccumulator( + (previousDisplayOrder): SubscriberWrapper[] => { + return getSubscribersInDisplayOrder(subscribersOnScreen, previousDisplayOrder ?? []); + }, + [subscribersOnScreen] + ).current; +}; + +export default useSubscribersInDisplayOrder; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useToolbarButtons.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useToolbarButtons.tsx new file mode 100644 index 00000000..5758c068 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useToolbarButtons.tsx @@ -0,0 +1,131 @@ +import { RefObject, useEffect, useRef, useState } from 'react'; +import throttle from '@common/execution/throttle'; +import ResizeObserverPolyfill from 'resize-observer-polyfill'; +import { RIGHT_PANEL_BUTTON_COUNT } from '../utils/constants'; + +export type UseToolbarButtonsProps = { + timeRoomNameRef: RefObject; + toolbarRef: RefObject; + mediaControlsRef: RefObject; + overflowAndExitRef: RefObject; + rightPanelControlsRef: RefObject; + numberOfToolbarButtons: number; +}; + +export type UseToolbarButtons = { + displayTimeRoomName: boolean; + centerButtonLimit: number; + rightButtonLimit: number; +}; + +/** + * React hook to determine which buttons should be displayed on the toolbar. + * @param {UseToolbarButtonsProps} props - The props for the hook + * @property {RefObject} toolbarRef - The ref for the Toolbar + * @property {RefObject} mediaControlsRef - The ref for the audio and video controls + * @property {RefObject} overflowAndExitRef - The ref for the overflow and exit buttons + * @property {RefObject} rightPanelControlsRef - The ref for the right panel buttons + * @returns {UseToolbarButtons} The center and right toolbar buttons' limits, and whether to display the TimeRoomNameMeetingRoom component + */ +const useToolbarButtons = ({ + timeRoomNameRef, + toolbarRef, + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, +}: UseToolbarButtonsProps): UseToolbarButtons => { + const observer = useRef(undefined); + const [displayTimeRoomName, setDisplayTimeRoomName] = useState(false); + + const [centerButtonLimit, setCenterButtonLimit] = useState(0); + const [rightButtonLimit, setRightButtonLimit] = useState(0); + const buttonWidth = 60; + + useEffect(() => { + if (toolbarRef.current && !observer.current) { + const throttledSetToolbarButtons = throttle( + () => { + if ( + !( + timeRoomNameRef.current && + toolbarRef.current && + mediaControlsRef.current && + overflowAndExitRef.current && + rightPanelControlsRef.current + ) + ) { + return; + } + + const toolbarStyle = window.getComputedStyle(toolbarRef.current); + const toolbarPadding = + parseFloat(toolbarStyle.paddingLeft) + parseFloat(toolbarStyle.paddingRight); + const rightPanelControlsStyle = window.getComputedStyle(rightPanelControlsRef.current); + const rightPanelMargin = parseFloat(rightPanelControlsStyle.marginLeft); + const timeRoomNameStyle = window.getComputedStyle(timeRoomNameRef.current); + const timeRoomNameMargin = parseFloat(timeRoomNameStyle.marginRight); + const necessaryComponentsWidth = + mediaControlsRef.current.clientWidth + + overflowAndExitRef.current.clientWidth + + toolbarPadding + + rightPanelMargin + + timeRoomNameMargin; + + const spaceForExtraButtons = Math.max( + 0, + toolbarRef.current.clientWidth - necessaryComponentsWidth + ); + const maxButtons = Math.floor(spaceForExtraButtons / buttonWidth); + + // We reserve a few buttons for the right panel + const maxButtonsForCenter = numberOfToolbarButtons - RIGHT_PANEL_BUTTON_COUNT; + // If there's more buttons able to be displayed, we only display the max for the center of the toolbar + const toolbarCenterLimit = + maxButtons > maxButtonsForCenter ? maxButtonsForCenter : maxButtons; + + setDisplayTimeRoomName(maxButtons > numberOfToolbarButtons + 1); + setCenterButtonLimit(toolbarCenterLimit); + setRightButtonLimit(Math.min(numberOfToolbarButtons, maxButtons)); + }, + 300, + { leading: true, trailing: false } + ); + + observer.current = new ResizeObserverPolyfill(() => { + throttledSetToolbarButtons(); + }); + } + + if (!(toolbarRef.current && observer.current)) { + return; + } + + // The cleanup function may not be pointing to the correct object/ref by the time it executes. + // We keep its reference so the cleanup function runs correctly. + const observedToolbar = toolbarRef.current; + + observer.current.observe(toolbarRef.current); + + return () => { + if (observedToolbar) { + observer.current?.unobserve(observedToolbar); + } + }; + }, [ + mediaControlsRef, + overflowAndExitRef, + rightPanelControlsRef, + numberOfToolbarButtons, + toolbarRef, + timeRoomNameRef, + ]); + + return { + displayTimeRoomName, + centerButtonLimit, + rightButtonLimit, + }; +}; + +export default useToolbarButtons; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useUserContext.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useUserContext.tsx new file mode 100644 index 00000000..a83511d7 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useUserContext.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { UserContext, UserContextType } from '../Context/user'; + +/** + * React hook to access the User context containing user information. + * @returns {UserContextType} - The current context value for the User Context. + */ +const useUserContext = (): UserContextType => { + const context = useContext(UserContext); + if (!context) { + throw new Error('UserContext must be used within a UserProvider'); + } + return context; +}; + +export default useUserContext; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWaitingRoom.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWaitingRoom.ts new file mode 100644 index 00000000..dd8ed15c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWaitingRoom.ts @@ -0,0 +1,104 @@ +import { useState, useEffect, useEffectEvent } from 'react'; +import type { MouseEvent, TouchEvent } from 'react'; +import usePreviewPublisherContext from './usePreviewPublisherContext'; +import useBackgroundPublisherContext from './useBackgroundPublisherContext'; +import useSessionKeyParam from './useSessionKeyParam'; +import useDecodedSessionKey from './useDecodedSessionKey'; +import { getStorageItem, STORAGE_KEYS } from '../utils/storage'; +import { DEVICE_ACCESS_STATUS } from '../utils/constants'; +import { env } from '../env'; + +const useWaitingRoom = () => { + const { sessionKey, sessionKeyStatus } = useSessionKeyParam(); + + const { roomName } = useDecodedSessionKey({ + sessionKey, + sessionKeyStatus, + }); + + const { initLocalPublisher, publisher, accessStatus, destroyPublisher, isVideoLoading } = + usePreviewPublisherContext(); + + const { initBackgroundLocalPublisher, publisher: backgroundPublisher } = + useBackgroundPublisherContext(); + + const [anchorEl, setAnchorEl] = useState(null); + const [openAudioInput, setOpenAudioInput] = useState(false); + const [openVideoInput, setOpenVideoInput] = useState(false); + const [openAudioOutput, setOpenAudioOutput] = useState(false); + const [username, setUsername] = useState(getStorageItem(STORAGE_KEYS.USERNAME) ?? ''); + + const stableInitLocalPublisher = useEffectEvent(() => { + if (!publisher) { + initLocalPublisher(); + } + + return () => { + if (publisher) { + destroyPublisher(); + } + }; + }); + + useEffect(() => { + return stableInitLocalPublisher(); + }, [publisher]); + + useEffect(() => { + if (!backgroundPublisher) { + initBackgroundLocalPublisher(); + } + }, [initBackgroundLocalPublisher, backgroundPublisher]); + + const handleAudioInputOpen = ( + event: MouseEvent | TouchEvent + ) => { + setAnchorEl(event.currentTarget); + setOpenAudioInput(true); + }; + + const handleVideoInputOpen = ( + event: MouseEvent | TouchEvent + ) => { + setAnchorEl(event.currentTarget); + setOpenVideoInput(true); + }; + + const handleAudioOutputOpen = ( + event: MouseEvent | TouchEvent + ) => { + setAnchorEl(event.currentTarget); + setOpenAudioOutput(true); + }; + + const handleClose = () => { + setAnchorEl(null); + setOpenAudioInput(false); + setOpenAudioOutput(false); + setOpenVideoInput(false); + }; + + const isRoomReady = + env.WAITING_ROOM_ALLOW_DEVICE_SELECTION && + accessStatus === DEVICE_ACCESS_STATUS.ACCEPTED && + !isVideoLoading; + + return { + anchorEl, + openAudioInput, + openVideoInput, + openAudioOutput, + username, + setUsername, + accessStatus, + isRoomReady, + roomName, + sessionKey, + handleAudioInputOpen, + handleVideoInputOpen, + handleAudioOutputOpen, + handleClose, + }; +}; + +export default useWaitingRoom; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWindowWidth.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWindowWidth.tsx new file mode 100644 index 00000000..ea6fb520 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/hooks/useWindowWidth.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +/** + * React hook to find the current window width. + * @returns {number} - How wide the current window is + */ +const useWindowWidth = () => { + const [width, setWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleResize = () => setWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + return width; +}; + +export default useWindowWidth; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/i18n.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/i18n.ts new file mode 100644 index 00000000..0494d947 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/i18n.ts @@ -0,0 +1,23 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import resources from './locales'; +import { env } from './env'; + +void i18n + // detect user language: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + .use(initReactI18next) + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + fallbackLng: env.I18N_FALLBACK_LANGUAGE, + supportedLngs: env.I18N_SUPPORTED_LANGUAGES, + resources, + + /** + * Suppress the warning about using the fallback language when a translation key is missing in the current language. + */ + showSupportNotice: false, + }); + +export default i18n; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/index.d.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/index.d.ts new file mode 100644 index 00000000..9fde91e3 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/index.d.ts @@ -0,0 +1,33 @@ +/** + * Attributes available on the vera-room custom element. + * These map to the bridge attributes used for configuration. + */ +type VeraRoomAttributes = { + /** The entry point identifier for logging and session source tracking */ + 'entry-point'?: string; + + /** Session identifier to join an existing session directly */ + 'session-identifier'?: string; + + /** + * BCP-47 language tag for the UI locale (e.g. 'en', 'es', 'it'). + * Falls back to the browser's detected language when not provided. + */ + language?: string; +}; + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + /** + * Vera embeddable web component for video conferencing. + */ + 'vera-room': React.DetailedHTMLProps< + React.HTMLAttributes & VeraRoomAttributes, + HTMLElement + >; + } + } +} + +export {}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/de.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/de.json new file mode 100644 index 00000000..13a768b4 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/de.json @@ -0,0 +1,312 @@ +{ + "archiveList.archive.createdAt": "Erstellt: {{createdAt}}", + "archiveList.archive.index": "Aufzeichnung {{index}}", + "archiveList.download.tooltip": "Aufzeichnung {{id}} herunterladen", + "archiveList.download": "Herunterladen", + "archiveList.empty": "Das Meeting wurde nicht aufgezeichnet", + "archiveList.error.text": "Fehler beim Laden der Aufzeichnungen für dieses Meeting", + "archiveList.error.tooltip": "Diese Aufzeichnung ist fehlgeschlagen oder abgelaufen", + "archiveList.label": "Aufzeichnungen herunterladen", + "archiveList.loading": "Wir verarbeiten Ihre Aufzeichnung", + "archiveList.loading.subtitle": "Warten Sie einige Sekunden, um Ihre Aufzeichnung herunterzuladen", + "advancedSettings.title": "Einstellungen", + "advancedSettings.open": "Einstellungen öffnen", + "advancedSettings.close": "Einstellungen schließen", + "advancedSettings.ariaLabel": "Einstellungen öffnen", + "advancedSettings.tabs.general": "Allgemein", + "advancedSettings.tabs.video": "Video", + "advancedSettings.tabs.audio": "Audio", + "advancedSettings.tabs.statistics": "Statistiken", + "advancedSettings.general.resetButton": "Standardwerte zurücksetzen", + "advancedSettings.general.resetDescription": "Stellt alle Einstellungen auf ihre Standardwerte zurück", + "advancedSettings.video.bitrate.label": "Bitrate", + "advancedSettings.video.bitrate.options.default": "Standard", + "advancedSettings.video.bitrate.options.bw_saver": "Bandbreitensparend", + "advancedSettings.video.bitrate.options.extra_bw_saver": "Zusätzliche Bandbreitenersparnis", + "advancedSettings.video.bitrate.options.custom": "Benutzerdefiniert", + "advancedSettings.video.bitrate.description": "Mit der Vonage Video API können Sie Bitrate-Presets verwenden oder eine benutzerdefinierte maximale Video-Bitrate festlegen.", + "advancedSettings.video.customBitrate.label": "Benutzerdefinierte Bitrate", + "advancedSettings.video.customBitrate.description": "Legen Sie eine maximale Video-Bitrate zwischen 5 kbps und 10 Mbps fest.", + "advancedSettings.video.customBitrate.minimum": "5 kbps", + "advancedSettings.video.customBitrate.maximum": "10 Mbps", + "advancedSettings.video.customBitrate.units.lower": "kbps", + "advancedSettings.video.customBitrate.units.higher": "Mbps", + "advancedSettings.video.codec.label": "Codec", + "advancedSettings.video.codec.options.automatic": "Automatisch", + "advancedSettings.video.codec.options.manual": "Manuell", + "advancedSettings.video.codec.description": "Das SDK wählt automatisch den besten Codec basierend auf Netzwerkbedingungen und Teilnehmerfähigkeiten aus. Änderungen gelten ab dem nächsten Anruf.", + "advancedSettings.video.codec.priority.label": "Codec-Priorität", + "advancedSettings.video.codec.priority.description": "Ziehen Sie die Einträge, um die bevorzugte Codec-Reihenfolge vom ersten bis zum letzten festzulegen.", + "advancedSettings.video.codec.priority.options.vp9": "VP9", + "advancedSettings.video.codec.priority.options.vp8": "VP8", + "advancedSettings.video.codec.priority.options.h264": "H.264", + "advancedSettings.video.frameRate.label": "Bildrate", + "advancedSettings.video.frameRate.options.30": "30 FPS", + "advancedSettings.video.frameRate.options.15": "15 FPS", + "advancedSettings.video.frameRate.options.7": "7 FPS", + "advancedSettings.video.frameRate.options.1": "1 FPS", + "advancedSettings.video.resolution.label": "Auflösung", + "advancedSettings.video.error.resolutionNotSupported": "Die ausgewählte Auflösung wird von Ihrem Gerät nicht unterstützt.", + "advancedSettings.video.error.frameRateNotSupported": "Die ausgewählte Bildrate wird von Ihrem Gerät nicht unterstützt.", + "advancedSettings.video.error.bitrateNotSupported": "Die ausgewählte Bitrate konnte nicht angewendet werden.", + "advancedSettings.audio.bitrate.label": "Audio-Bitrate", + "advancedSettings.audio.bitrate.description": "Verwendet automatisch den Standardwert des SDK, oder wählen Sie Benutzerdefiniert, um die maximale Audio-Kodierungsbitrate für die Sitzung festzulegen.", + "advancedSettings.audio.bitrate.options.automatic": "Automatisch", + "advancedSettings.audio.bitrate.options.custom": "Benutzerdefiniert", + "advancedSettings.audio.bitrate.customLabel": "Benutzerdefinierte Audio-Bitrate", + "advancedSettings.audio.bitrate.minimum": "6 kbps", + "advancedSettings.audio.bitrate.maximum": "510 kbps", + "advancedSettings.audio.bitrate.currentValue": "{{value}} kbps", + "advancedSettings.audio.enableDtx.label": "Opus DTX aktivieren", + "advancedSettings.audio.enableDtx.description": "Wenn aktiviert, reduziert die diskontinuierliche Opus-Übertragung die Audio-Bandbreite während Sprechpausen.", + "advancedSettings.audio.publisherAudioFallback.label": "Audio-Fallback des Publishers", + "advancedSettings.audio.publisherAudioFallback.description": "Wenn aktiviert, wird Ihr Video bei schlechter Netzwerkqualität auf anderen Geräten nicht mehr gerendert, um das Audio zu erhalten.", + "advancedSettings.audio.subscriberAudioFallback.label": "Audio-Fallback des Subscribers", + "advancedSettings.audio.subscriberAudioFallback.description": "Wenn aktiviert, empfangen Sie bei schlechter Netzwerkqualität nur Audio von anderen Teilnehmern.", + "advancedSettings.audio.nextCallWarningWaitingRoom": "Änderungen werden wirksam, wenn Sie dem Anruf beitreten.", + "advancedSettings.audio.nextCallWarningMeetingRoom": "Änderungen werden beim nächsten Anruf oder nach dem Aktualisieren der Seite wirksam.", + "advancedSettings.statistics.collection.enablePublisher.label": "Bandbreitenschätzung des Publishers aktivieren", + "advancedSettings.statistics.collection.enablePublisher.description": "Wenn aktiviert, wird die geschätzte verfügbare ausgehende Bandbreite für den lokalen Publisher angezeigt.", + "advancedSettings.statistics.groups.publisher": "Publisher", + "advancedSettings.statistics.groups.subscribers": "Subscribers", + "advancedSettings.statistics.empty": "Es sind noch keine Statistiken verfügbar.", + "advancedSettings.statistics.sections.audio": "Audio", + "advancedSettings.statistics.sections.video": "Video", + "advancedSettings.statistics.metrics.packetsSent": "Gesendete Pakete", + "advancedSettings.statistics.metrics.packetsLostSent": "Verlorene Pakete (Senden)", + "advancedSettings.statistics.metrics.bytesSent": "Gesendete Bytes", + "advancedSettings.statistics.metrics.packetsReceived": "Empfangene Pakete", + "advancedSettings.statistics.metrics.packetsLostReceived": "Verlorene Pakete (Empfang)", + "advancedSettings.statistics.metrics.bytesReceived": "Empfangene Bytes", + "advancedSettings.statistics.metrics.estimatedBandwidth": "Geschätzte Bandbreite", + "advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth": "Geschätzte Bandbreite des Remote-Publishers", + "advancedSettings.statistics.metrics.resolution": "Auflösung", + "advancedSettings.statistics.metrics.frameRate": "Bildrate", + "advancedSettings.statistics.metrics.decodedFrameRate": "Dekodierte Bildrate", + "advancedSettings.statistics.metrics.bitrate": "Bitrate", + "advancedSettings.statistics.metrics.packetLoss": "Paketverlust", + "advancedSettings.statistics.metrics.freezeCount": "Anzahl der Einfrierungen", + "advancedSettings.statistics.metrics.totalFreezesDuration": "Gesamtdauer der Einfrierungen", + "advancedSettings.statistics.metrics.codec": "Codec", + "advancedSettings.statistics.metrics.videoLayer": "Schicht {{index}} ({{codec}})", + "backgroundEffects.limit": "Sie haben das Maximum für benutzerdefinierte Bilder erreicht", + "backgroundEffects.title": "Videoeffekte", + "backgroundEffects.addBackground": "Hintergrund ändern", + "backgroundEffects.video.disabled": "Ihre Kamera ist ausgeschaltet.", + "backgroundEffects.invalidFileType": "Nur JPG-, PNG-, GIF- oder BMP-Bilder sind zulässig.", + "backgroundEffects.fileTooLarge": "Bild muss kleiner als {{maxSize}}MB sein.", + "backgroundEffects.processingError": "Fehler beim Verarbeiten des hochgeladenen Bildes.", + "backgroundEffects.storageError": "Fehler beim Speichern des Bildes.", + "backgroundEffects.maxSize": "Max {{maxSize}}MB", + "backgroundEffects.removeBackground": "Hintergrund entfernen", + "backgroundEffects.slightBlur": "Leichte Hintergrundunschärfe", + "backgroundEffects.strongBlur": "Starke Hintergrundunschärfe", + "backgroundEffects.yourBackground": "Ihr Hintergrund", + "backgroundEffects.deleteTooltipInUse": "Sie können diesen Hintergrund nicht entfernen, während er verwendet wird", + "backgroundEffects.deleteTooltip": "Hintergrund löschen", + "backgroundEffects.deleteAriaLabel": "Benutzerdefinierten Hintergrund löschen", + "backgroundEffects.backgrounds.bookshelfRoom": "Bücherregal-Zimmer", + "backgroundEffects.backgrounds.busyRoom": "Belebtes Zimmer", + "backgroundEffects.backgrounds.duneView": "Dünenblick", + "backgroundEffects.backgrounds.hogwarts": "Hogwarts", + "backgroundEffects.backgrounds.library": "Bibliothek", + "backgroundEffects.backgrounds.newYork": "New York", + "backgroundEffects.backgrounds.plane": "Flugzeug", + "backgroundEffects.backgrounds.whiteRoom": "Weißes Zimmer", + "button.apply": "Anwenden", + "button.camera": "Kamera", + "button.cancel": "Abbrechen", + "button.close": "Schließen", + "button.createRoom": "Neuen Raum erstellen", + "button.startNewRoom": "Neues Videomeeting starten", + "button.joinExistingMeeting": "Bestehendem Meeting beitreten", + "button.joinWaitingRoom": "Warteraum betreten", + "button.join": "Meeting beitreten", + "button.mute": "Stummschalten", + "button.send": "Senden", + "button.speaker": "Lautsprecher", + "captions.ariaLabel": "Untertitel-Schaltfläche", + "captions.disable": "Untertitel deaktivieren", + "captions.enable": "Untertitel aktivieren", + "captions.errors": "Untertitelfehler: {{captionsErrorResponse}}", + "chat.ariaLabel": "Chat umschalten", + "chat.close": "Chat schließen", + "chat.copied": "Kopiert", + "chat.copy": "In Zwischenablage kopieren", + "chat.input.placeholder": "Nachricht senden", + "chat.meetingUrl": "Meeting-URL:", + "chat.open": "Chat öffnen", + "chat.title": "Chat", + "common.or": "oder", + "connectionAlert.quality.message": "Bitte überprüfen Sie Ihre Verbindung. Ihr Video kann deaktiviert werden, um das Benutzererlebnis zu verbessern", + "connectionAlert.quality.title": "Videoquadrat-Problem", + "connectionAlert.reconnecting.message": "Bitte überprüfen Sie Ihre Netzwerkverbindung", + "connectionAlert.reconnecting.title": "Verbindung unterbrochen", + "deviceAccessAlert.askDeviceMessage": "Um dem Videoraum beizutreten, verlangt Ihr Browser Zugriff auf Ihre Kamera und Ihr Mikrofon.", + "deviceAccessAlert.deniedDeviceMessage": "Es scheint, dass Ihr Browser keine Berechtigung für den Zugriff auf Ihre Kamera und/oder Ihr Mikrofon hat. Setzen Sie die Berechtigung über die Benutzeroberfläche des Browsers zurück.", + "deviceAccessAlert.imageAlt": "Zugriffsdialog", + "devices.audio.ariaLabel": "Audio-Geräte Dropdown", + "devices.audio.defaultLabel": "Systemstandard", + "devices.audio.disable": "Mikrofon deaktivieren", + "devices.audio.disabled": "Mikrofonsteuerung ist in dieser Anwendung deaktiviert", + "devices.audio.enable": "Mikrofon aktivieren oder Leertaste halten, um vorübergehend stummschaltung aufzuheben", + "devices.audio.microphone.ariaLabel": "Audio umschalten", + "devices.audio.microphone.full": "Mikrofon", + "devices.audio.microphone.short": "Mikro", + "devices.audio.microphone.state.on": "Mikrofon aktivieren", + "devices.audio.microphone.state.off": "Mikrofon deaktivieren", + "devices.audio.noiseSuppression": "Erweiterte Rauschunterdrückung", + "devices.audio.speakers.full": "Lautsprecher", + "devices.buttons.ariaLabel": "Geteilte Schaltfläche", + "devices.settings.ariaLabel": "Geräteeinstellungen", + "devices.select.unknownDevice": "Gerät", + "devices.select.errorContext": "{{device}} konnte nicht ausgewählt werden. Bitte überprüfen Sie die Geräteberechtigungen und versuchen Sie es erneut.", + "devices.select.accessDenied": "Der Zugriff auf {{device}} wurde verweigert. Bitte überprüfen Sie die Browserberechtigungen.", + "devices.select.trackUnavailable": "{{device}} ist derzeit nicht verfügbar. Bitte überprüfen Sie die Geräteverbindung.", + "devices.video.ariaLabel": "Videogeräte Dropdown", + "devices.video.blur.ariaLabel": "Hintergrundunschärfe umschalten", + "devices.video.blur.label": "Ihren Hintergrund unschärfen", + "devices.video.camera.ariaLabel": "Video umschalten", + "devices.video.camera.switch": "Kamera wechseln", + "devices.video.camera.state.on": "Kamera aktivieren", + "devices.video.camera.state.off": "Kamera deaktivieren", + "devices.video.camera.full": "Kamera", + "devices.video.camera.front": "vordere", + "devices.video.camera.rear": "hintere", + "devices.video.camera.switchError": "Die {{facingMode}} Kamera konnte nicht gewechselt werden", + "devices.video.camera.accessError": "Die {{facingMode}} Kamera konnte nicht verwendet werden. Überprüfen Sie Ihre Kameragerechte und versuchen Sie es erneut.", + "devices.video.camera.noTrackError": "Die {{facingMode}} Kamera ist nicht korrekt gestartet. Versuchen Sie eine andere Kamera.", + "devices.video.camera.noDeviceError": "Es wurde keine {{facingMode}} Kamera gefunden", + "devices.video.camera.noCurrentDeviceError": "Keine Kamera ausgewählt. Wählen Sie zuerst eine Kamera aus.", + "devices.video.camera.selectedDevice": "Ausgewählte Kamera: {{deviceName}}", + "devices.video.disable": "Video deaktivieren", + "devices.video.disabled": "Kamerasteuerung ist in dieser Anwendung deaktiviert", + "devices.video.enable": "Video aktivieren", + "emoji.ariaLabel": "Emoji-Menü öffnen", + "emoji.tooltip": "Drücken Sie sich aus", + "errors.unknown": "Unbekannter Fehler aufgetreten", + "feedbackForm.ariaLabel": "Problemmeldeformular umschalten", + "feedbackForm.close": "Problemmeldeformular schließen", + "feedbackForm.disclaiamer.label": "Bitte fügen Sie keine sensiblen Informationen ein.", + "feedbackForm.disclaiamer.screenshot": "Ein Screenshot hilft uns, das Problem besser zu verstehen. (optional)", + "feedbackForm.field.issue.label": "Beschreiben Sie Ihr Problem", + "feedbackForm.field.issue.type": "Beschreibung", + "feedbackForm.field.name.label": "Sagen Sie uns Ihren Namen", + "feedbackForm.field.name.type": "Ihr Name", + "feedbackForm.field.title.label": "Was haben Sie getan, als Sie dieses Problem bemerkt haben?", + "feedbackForm.field.title.type": "Titel", + "feedbackForm.helperText.error": "{{field}} ist erforderlich und muss weniger als {{limit}} Zeichen enthalten", + "feedbackForm.open": "Problemmeldeformular öffnen", + "feedbackForm.submitted.content": "Wir schätzen Ihre Eingabe sehr, da sie uns hilft, die App zu verbessern und Probleme zu beheben.", + "feedbackForm.submitted.error": "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.", + "feedbackForm.submitted.thanks": "Vielen Dank für Ihr Feedback!", + "feedbackForm.submitted.track": "Verfolgen Sie Ihren Fortschritt hier.", + "feedbackForm.title": "Problem melden", + "filePicker.addScreenshot": "Screenshot hinzufügen", + "filePicker.attachedScreenshot": "Screenshot angehängt", + "filePicker.capture": "Screenshot erfassen", + "filePicker.delete": "Screenshot löschen", + "filePicker.sizeLimit": "Die maximale Uploadgröße beträgt 20MB. Bitte laden Sie eine andere Datei hoch.", + "footer.github.title": "Vonage Video Referenzanwendung", + "imageStorage.imagesExceedSize": "Bilder sind zu groß zum Speichern (~4MB max).", + "imageStorage.storageFailure": "Fehler beim Speichern von Bildern in localStorage.", + "imageStorage.duplicateImage": "Dieses Bild wurde bereits hinzugefügt.", + "imageStorage.fileReadError": "Fehler beim Lesen der Bilddatei.", + "imageStorage.invalidExtension": "Ungültige Bilderweiterung.", + "imageStorage.invalidUrl": "Ungültige Bild-URL.", + "imageStorage.convertError": "Bild konnte nicht konvertiert werden.", + "imageStorage.loadError": "Bild konnte nicht geladen werden.", + "githubTooltip": "Besuchen Sie unser GitHub-Repository", + "goodBye.title": "Raum erneut beitreten", + "goodBye.back": "Landingpage ansehen", + "goodBye.reEnter": "Zurück zum Meeting", + "goodbye.default.header": "Sie haben das Meeting verlassen", + "goodbye.default.message": "Vielen Dank für Ihre Teilnahme!", + "landing.welcome.subtitle": "Nutzen Sie Video, um Ihr Business zu transformieren und die Kundenzufriedenheit zu steigern.", + "landing.welcome.title.1": "Verbessern", + "landing.welcome.title.2": "Sie Ihre", + "landing.welcome.title.3": "Videokommunikation", + "landing.primary.word": "Video", + "layout.tooltip.isPinningPresent": "Layout kann nicht gewechselt werden, während ein Teilnehmer angeheftet ist", + "layout.tooltip.isScreenSharePresent": "Layout kann nicht gewechselt werden, während Bildschirmfreigabe aktiv ist", + "layout.tooltip.switchToActiveSpeaker": "Zu Active Speaker Layout wechseln", + "layout.tooltip.switchToGrid": "Zu Rasterlayout wechseln", + "mutedAlert.message.forceMuted": "Sie wurden von einem anderen Teilnehmer stummgeschaltet. Klicken Sie auf das Mikrofon, um die Stummschaltung aufzuheben.", + "mutedAlert.message.muted": "Sprechen Sie? Ihr Mikrofon ist ausgeschaltet. Klicken Sie auf das Mikrofon, um es einzuschalten.", + "participants.list.ariaLabel": "Teilnehmerliste umschalten", + "participants.list.close": "Teilnehmerliste schließen", + "participants.list.open": "Teilnehmerliste öffnen", + "participants.maxPin": "Sie können keine weiteren Kacheln anheften", + "participants.mute.dialog.content": "{{participantName}} für alle im Anruf stummschalten? Nur {{participantName}} kann sich selbst unmute.", + "participants.mute.tooltip": "Mikrofon von {{participantName}} stummschalten", + "participants.pin": "{{participantName}} anheften", + "participants.pin.video": "Video von {{participantName}} anheften", + "participants.screen": "Bildschirm von {{participantName}}", + "participants.search": "Teilnehmer suchen", + "participants.title": "Teilnehmer", + "participants.unpin": "{{participantName}} losreißen", + "participants.unpin.video": "Video von {{participantName}} losreißen", + "publishingErrors.accessDenied.message": "Es scheint, dass Ihr Browser den Zugriff auf Ihr {{device}} blockiert. Setzen Sie den Genehmigungsstatus über die Benutzeroberfläche des Browsers zurück.", + "publishingErrors.accessDenied.title": "{{device}}-Zugriff verweigert", + "publishingErrors.blocked.message": "Wir haben Schwierigkeiten, Sie mit anderen im Meetingraum zu verbinden. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.", + "publishingErrors.blocked.title": "Schwierigkeiten beim Beitreten zum Raum", + "subscribingErrors.blocked.message": "Wir haben Schwierigkeiten, Sie mit anderen im Meetingraum zu verbinden. Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut.", + "subscribingErrors.blocked.title": "Schwierigkeiten beim Beitreten zum Raum", + "recording.start.dialog.content": "Stellen Sie sicher, dass alle bereit sind! Sie können die Aufzeichnung nach dem Verlassen des Raums von der Seite \"Auf Wiedersehen\" herunterladen.", + "recording.start.dialog.title": "Aufzeichnung starten?", + "recording.start.title": "Aufzeichnung starten", + "recording.stop.dialog.content": "Sie können die Aufzeichnung nach dem Verlassen des Raums von der Seite \"Auf Wiedersehen\" herunterladen.", + "recording.stop.dialog.title": "Aufzeichnung stoppen?", + "recording.stop.title": "Aufzeichnung stoppen", + "recording.tooltip.ariaLabel": "Videolayout", + "room.exit.ariaLabel": "Beenden", + "room.exit.tooltip": "Meeting verlassen", + "room.input.helper": "Kann nicht leer sein und darf keine Leerzeichen oder Sonderzeichen enthalten", + "room.input.label": "Raumname", + "screenSharing.dialog.action": "Starten Sie, Ihren Bildschirm zu teilen", + "screenSharing.dialog.content": "Es sieht so aus, als würde jemand anderes seinen Bildschirm teilen. Wenn Sie fortfahren, wird ihr Bildschirm nicht mehr geteilt.", + "screenSharing.dialog.title": "Möchten Sie Ihren Bildschirm teilen?", + "screenSharing.dialog.hiddenMessage": "Sie teilen gerade Ihren Bildschirm.", + "screenSharing.title.start": "Bildschirmfreigabe starten", + "screenSharing.title.stop": "Bildschirmfreigabe stoppen", + "screenSharing.tooltip.ariaLabel": "Bildschirmfreigabe starten oder stoppen", + "soundTest.start": "Lautsprecher testen", + "soundTest.stop": "Test stoppen", + "toolbar.tooltip.ariaLabel": "Menü für zusätzliche Symbolleistenelement öffnen", + "toolbar.tooltip.title": "Auf zusätzliche Symbolleistenelemente zugreifen", + "unsupportedBrowser.header": "Ihr Browser wird nicht unterstützt.", + "unsupportedBrowser.message": "Überprüfen Sie die Liste und wählen Sie einen unterstützten Browser.", + "unsupportedBrowser.supported.visitPage": "Seite besuchen", + "unsupportedBrowser.supported.title": "Unterstützte Browser:", + "unknown.device": "Unbekanntes Gerät", + "user.unknown": "unbekannter Benutzer", + "user.you": "Sie", + "waitingRoom.unsupportedFeature.tooltip": "Ihr Browser unterstützt diese Funktion nicht", + "waitingRoom.devices.noDevicesFound": "Keine Geräte gefunden", + "waitingRoom.title": "Vorbereitung zum Beitreten", + "waitingRoom.user.input.title": "Geben Sie Ihren Namen ein", + "waitingRoom.user.input.label": "Name", + "waitingRoom.precallNetworkTest.title": "Vor-Anruf-Netzwerktest", + "waitingRoom.precallNetworkTest.subtitle": "Testen Sie Ihren Gerätezugriff, Netzwerkkonnektivität und Anrufqualität, bevor Sie beitreten", + "waitingRoom.precallNetworkTest.stopTest": "Test stoppen", + "waitingRoom.precallNetworkTest.stoppedTitle": "Test gestoppt", + "waitingRoom.precallNetworkTest.stoppedMessage": "Sie haben den Netzwerktest gestoppt. Sie können es later erneut versuchen, wenn Sie bereit sind.", + "waitingRoom.precallNetworkTest.retryTest": "Test erneut versuchen", + "waitingRoom.precallNetworkTest.audio": "Audio", + "waitingRoom.precallNetworkTest.video": "Video", + "waitingRoom.precallNetworkTest.qualityLabel": "Qualität:", + "waitingRoom.precallNetworkTest.error": "Test fehlgeschlagen", + "waitingRoom.precallNetworkTest.audioSupported": "Audio wird unterstützt", + "waitingRoom.precallNetworkTest.audioNotSupported": "Audio wird nicht unterstützt", + "waitingRoom.precallNetworkTest.videoSupported": "Video wird unterstützt", + "waitingRoom.precallNetworkTest.videoNotSupported": "Video wird nicht unterstützt", + "waitingRoom.user.input.error": "Name kann nicht leer sein oder Sonderzeichen enthalten.", + "zoom.reset": "Zoom zurücksetzen", + "zoom.start": "Zoom starten", + "zoom.out": "Zoom heransfahren", + "zoom.in": "Zoom hinausfahren", + "zoom.current": "Aktueller Zoom", + "errorPage.title": "Etwas ist schiefgelaufen", + "errorPage.description": "Die Anwendung konnte nicht gestartet werden. Bitte laden Sie die Seite neu oder wenden Sie sich an den Support, wenn das Problem weiterhin besteht.", + "notification.dismiss": "Benachrichtigung schließen" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/en.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/en.json new file mode 100644 index 00000000..665ab36b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/en.json @@ -0,0 +1,320 @@ +{ + "archiveList.archive.createdAt": "Created: {{createdAt}}", + "archiveList.archive.index": "Recording {{index}}", + "archiveList.download.tooltip": "Download recording {{id}}", + "archiveList.download": "Download", + "archiveList.empty": "The meeting hasn't been recorded", + "archiveList.error.text": "There was an error loading recordings for this meeting", + "archiveList.error.tooltip": "This recording failed or is expired", + "archiveList.label": "Download recordings", + "archiveList.loading": "We are processing your recording", + "archiveList.loading.subtitle": "Wait a few seconds to download your recording", + "advancedSettings.title": "Settings", + "advancedSettings.open": "Open settings", + "advancedSettings.close": "Close settings", + "advancedSettings.ariaLabel": "open settings", + "advancedSettings.tabs.general": "General", + "advancedSettings.tabs.video": "Video", + "advancedSettings.tabs.audio": "Audio", + "advancedSettings.tabs.statistics": "Statistics", + "advancedSettings.general.resetButton": "Reset default values", + "advancedSettings.general.resetDescription": "Restore all settings to their default values", + "advancedSettings.video.bitrate.label": "Bitrate", + "advancedSettings.video.bitrate.options.default": "Default", + "advancedSettings.video.bitrate.options.bw_saver": "Bandwidth saver", + "advancedSettings.video.bitrate.options.extra_bw_saver": "Extra bandwidth saver", + "advancedSettings.video.bitrate.options.custom": "Custom", + "advancedSettings.video.bitrate.description": "Vonage Video API lets you use bitrate presets or define a custom maximum video bitrate.", + "advancedSettings.video.customBitrate.label": "Custom bitrate", + "advancedSettings.video.customBitrate.description": "Set a maximum video bitrate between 5 kbps and 10 Mbps.", + "advancedSettings.video.customBitrate.minimum": "5 kbps", + "advancedSettings.video.customBitrate.maximum": "10 Mbps", + "advancedSettings.video.customBitrate.units.lower": "kbps", + "advancedSettings.video.customBitrate.units.higher": "Mbps", + "advancedSettings.video.codec.label": "Codec", + "advancedSettings.video.codec.options.automatic": "Automatic", + "advancedSettings.video.codec.options.manual": "Manual", + "advancedSettings.video.codec.description": "The SDK automatically selects the best codec based on network conditions and participant capabilities. Changes apply on the next call.", + "advancedSettings.video.codec.priority.label": "Codec priority", + "advancedSettings.video.codec.priority.description": "Drag and drop to choose the preferred codec order from first to last.", + "advancedSettings.video.codec.priority.options.vp9": "VP9", + "advancedSettings.video.codec.priority.options.vp8": "VP8", + "advancedSettings.video.codec.priority.options.h264": "H.264", + "advancedSettings.video.frameRate.label": "Frame rate", + "advancedSettings.video.frameRate.options.30": "30 FPS", + "advancedSettings.video.frameRate.options.15": "15 FPS", + "advancedSettings.video.frameRate.options.7": "7 FPS", + "advancedSettings.video.frameRate.options.1": "1 FPS", + "advancedSettings.video.resolution.label": "Resolution", + "advancedSettings.video.error.resolutionNotSupported": "The selected resolution is not supported by your device.", + "advancedSettings.video.error.frameRateNotSupported": "The selected frame rate is not supported by your device.", + "advancedSettings.video.error.bitrateNotSupported": "The selected bitrate could not be applied.", + "advancedSettings.audio.bitrate.label": "Audio bitrate", + "advancedSettings.audio.bitrate.description": "Automatically uses the SDK default, or choose custom to define the maximum audio encoding bitrate sent to the session.", + "advancedSettings.audio.bitrate.options.automatic": "Automatic", + "advancedSettings.audio.bitrate.options.custom": "Custom", + "advancedSettings.audio.bitrate.customLabel": "Custom audio bitrate", + "advancedSettings.audio.bitrate.minimum": "6 kbps", + "advancedSettings.audio.bitrate.maximum": "510 kbps", + "advancedSettings.audio.bitrate.currentValue": "{{value}} kbps", + "advancedSettings.audio.enableDtx.label": "Enable Opus DTX", + "advancedSettings.audio.enableDtx.description": "When enabled, Opus discontinuous transmission reduces audio bandwidth during silence.", + "advancedSettings.audio.publisherAudioFallback.label": "Publisher audio fallback", + "advancedSettings.audio.publisherAudioFallback.description": "When enabled, your video stops rendering on other devices during poor network conditions to preserve audio.", + "advancedSettings.audio.subscriberAudioFallback.label": "Subscriber audio fallback", + "advancedSettings.audio.subscriberAudioFallback.description": "When enabled, you receive audio-only from other participants during poor network conditions.", + "advancedSettings.audio.nextCallWarningWaitingRoom": "Changes will take effect when you join the call.", + "advancedSettings.audio.nextCallWarningMeetingRoom": "Changes will take effect on your next call or after refreshing.", + "advancedSettings.statistics.collection.enablePublisher.label": "Enable publisher bandwidth estimate", + "advancedSettings.statistics.collection.enablePublisher.description": "When enabled, shows the estimated available outbound bandwidth for the local publisher.", + "advancedSettings.statistics.groups.publisher": "Publisher", + "advancedSettings.statistics.groups.subscribers": "Subscribers", + "advancedSettings.statistics.empty": "No statistics available yet.", + "advancedSettings.statistics.sections.audio": "Audio", + "advancedSettings.statistics.sections.video": "Video", + "advancedSettings.statistics.metrics.resolution": "Resolution", + "advancedSettings.statistics.metrics.frameRate": "Frame rate", + "advancedSettings.statistics.metrics.decodedFrameRate": "Decoded frame rate", + "advancedSettings.statistics.metrics.bitrate": "Bitrate", + "advancedSettings.statistics.metrics.packetLoss": "Packet loss", + "advancedSettings.statistics.metrics.freezeCount": "Freeze count", + "advancedSettings.statistics.metrics.totalFreezesDuration": "Total freezes duration", + "advancedSettings.statistics.metrics.codec": "Codec", + "advancedSettings.statistics.metrics.videoLayer": "Layer {{index}} ({{codec}})", + "advancedSettings.statistics.metrics.packetsSent": "Packets sent", + "advancedSettings.statistics.metrics.packetsLostSent": "Packets lost (send)", + "advancedSettings.statistics.metrics.bytesSent": "Bytes sent", + "advancedSettings.statistics.metrics.packetsReceived": "Packets received", + "advancedSettings.statistics.metrics.packetsLostReceived": "Packets lost (receive)", + "advancedSettings.statistics.metrics.bytesReceived": "Bytes received", + "advancedSettings.statistics.metrics.estimatedBandwidth": "Estimated bandwidth", + "advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth": "Remote publisher estimated bandwidth", + "backgroundEffects.limit": "You have reached the maximum custom images limit", + "backgroundEffects.title": "Video effects", + "backgroundEffects.addBackground": "Add Background", + "backgroundEffects.video.disabled": "Your camera is turned off.", + "backgroundEffects.invalidFileType": "Only JPG, PNG, GIF, or BMP images are allowed.", + "backgroundEffects.fileTooLarge": "Image must be less than {{maxSize}}MB.", + "backgroundEffects.processingError": "Failed to process uploaded image.", + "backgroundEffects.storageError": "Failed to store image.", + "backgroundEffects.maxSize": "Max {{maxSize}}MB", + "backgroundEffects.removeBackground": "Remove background", + "backgroundEffects.slightBlur": "Slight background blur", + "backgroundEffects.strongBlur": "Strong background blur", + "backgroundEffects.yourBackground": "Your Background", + "backgroundEffects.deleteTooltipInUse": "You can't remove this background while it's in use", + "backgroundEffects.deleteTooltip": "Delete background", + "backgroundEffects.deleteAriaLabel": "Delete custom background", + "backgroundEffects.backgrounds.bookshelfRoom": "Bookshelf Room", + "backgroundEffects.backgrounds.busyRoom": "Busy Room", + "backgroundEffects.backgrounds.duneView": "Dune View", + "backgroundEffects.backgrounds.hogwarts": "Hogwarts", + "backgroundEffects.backgrounds.library": "Library", + "backgroundEffects.backgrounds.newYork": "New York", + "backgroundEffects.backgrounds.plane": "Plane", + "backgroundEffects.backgrounds.whiteRoom": "White Room", + "button.apply": "Apply", + "button.camera": "Camera", + "button.cancel": "Cancel", + "button.close": "Close", + "button.createRoom": "Create a new room", + "button.startNewRoom": "Start a new video meeting", + "button.joinExistingMeeting": "Join existing meeting", + "button.joinWaitingRoom": "Join waiting room", + "button.join": "Join meeting", + "button.mute": "Mute", + "button.send": "Send", + "button.speaker": "Speaker", + "captions.ariaLabel": "captions button", + "captions.disable": "Disable captions", + "captions.enable": "Enable captions", + "captions.errors": "Captions error: {{captionsErrorResponse}}", + "chat.ariaLabel": "toggle chat", + "chat.close": "Close chat", + "chat.copied": "Copied", + "chat.copy": "Copy to clipboard", + "chat.input.placeholder": "Send a message", + "chat.meetingUrl": "Meeting URL:", + "chat.open": "Open chat", + "chat.title": "Chat", + "common.or": "or", + "connectionAlert.quality.message": "Please check your connectivity. Your video may be disabled to improve the user experience", + "connectionAlert.quality.title": "Video quality problem", + "connectionAlert.reconnecting.message": "Please verify your network connection", + "connectionAlert.reconnecting.title": "Lost connection", + "deviceAccessAlert.askDeviceMessage": "To join the video room, your browser will request access to your camera and microphone.", + "deviceAccessAlert.deniedDeviceMessage": "It seems your browser is blocked from accessing your camera and/or microphone. Reset the permission state through your browser's UI.", + "deviceAccessAlert.imageAlt": "Access Dialog", + "devices.audio.ariaLabel": "audio devices dropdown", + "devices.audio.defaultLabel": "System Default", + "devices.audio.disable": "Disable microphone", + "devices.audio.disabled": "Microphone control is disabled in this application", + "devices.audio.enable": "Enable microphone or hold Space to temporarily unmute", + "devices.audio.microphone.ariaLabel": "toggle audio", + "devices.audio.microphone.full": "Microphone", + "devices.audio.microphone.short": "Mic", + "devices.audio.microphone.state.on": "Enable microphone", + "devices.audio.microphone.state.off": "Disable microphone", + "devices.audio.noiseSuppression": "Advanced Noise Suppression", + "devices.audio.speakers.full": "Speakers", + "devices.buttons.ariaLabel": "split button", + "devices.settings.ariaLabel": "device settings", + "devices.select.unknownDevice": "device", + "devices.select.errorContext": "Unable to select {{device}}. Please check your device permissions and try again.", + "devices.select.accessDenied": "Access to the {{device}} was denied. Please check your browser permissions.", + "devices.select.trackUnavailable": "The {{device}} is currently unavailable. Please check your device connection.", + "devices.video.ariaLabel": "video devices dropdown", + "devices.video.blur.ariaLabel": "Toggle background blur", + "devices.video.blur.label": "Blur your background", + "devices.video.camera.ariaLabel": "toggle video", + "devices.video.camera.switch": "Switch camera", + "devices.video.camera.state.on": "Enable camera", + "devices.video.camera.state.off": "Disable camera", + "devices.video.camera.full": "Camera", + "devices.video.camera.front": "front", + "devices.video.camera.rear": "rear", + "devices.video.camera.switchError": "Could not switch to the {{facingMode}} camera", + "devices.video.camera.accessError": "Could not use the {{facingMode}} camera. Check your camera permissions and try again.", + "devices.video.camera.noTrackError": "The {{facingMode}} camera did not start correctly. Try another camera.", + "devices.video.camera.noDeviceError": "No {{facingMode}} camera was found", + "devices.video.camera.noCurrentDeviceError": "No camera is currently selected. Please select a camera first.", + "devices.video.camera.selectedDevice": "Selected camera: {{deviceName}}", + "devices.video.disable": "Disable video", + "devices.video.disabled": "Camera control is disabled in this application", + "devices.video.enable": "Enable video", + "emoji.ariaLabel": "open sendable emoji menu", + "emoji.tooltip": "Express yourself", + "errors.unknown": "Unknown error occurred", + "feedbackForm.ariaLabel": "toggle report issue form", + "feedbackForm.close": "Close report issue form", + "feedbackForm.disclaiamer.label": "Please do not include any sensitive information.", + "feedbackForm.disclaiamer.screenshot": "A screenshot will help us better understand the issue. (optional)", + "feedbackForm.field.issue.label": "Describe your issue", + "feedbackForm.field.issue.type": "Description", + "feedbackForm.field.name.label": "Tell us your name", + "feedbackForm.field.name.type": "Your name", + "feedbackForm.field.title.label": "When you noticed this issue, what were you trying to do?", + "feedbackForm.field.title.type": "Title", + "feedbackForm.helperText.error": "{{field}} is required and must be less than {{limit}} characters", + "feedbackForm.open": "Open report issue form", + "feedbackForm.submitted.content": "We greatly appreciate your input, as it helps us improve the app and address any issues.", + "feedbackForm.submitted.error": "Something went wrong. Please try again.", + "feedbackForm.submitted.thanks": "Thank you for your feedback!", + "feedbackForm.submitted.track": "Track your progress here.", + "feedbackForm.title": "Report Issue", + "filePicker.addScreenshot": "Add screenshot", + "filePicker.attachedScreenshot": "Attached screenshot", + "filePicker.capture": "Capture screenshot", + "filePicker.delete": "Delete screenshot", + "filePicker.sizeLimit": "The maximum upload size is 20MB. Please upload another file.", + "footer.github.title": "Vonage Video Reference Application", + "imageStorage.imagesExceedSize": "Images are too large to store (~4MB max).", + "imageStorage.storageFailure": "Failed to store images in localStorage.", + "imageStorage.duplicateImage": "This image is already added.", + "imageStorage.fileReadError": "Failed to read image file.", + "imageStorage.invalidExtension": "Invalid image extension.", + "imageStorage.invalidUrl": "Invalid image URL.", + "imageStorage.convertError": "Could not convert image.", + "imageStorage.loadError": "Could not load image.", + "githubTooltip": "Visit our GitHub Repo", + "goodBye.title": "Rejoining the room", + "goodBye.back": "View Landing Page", + "goodBye.reEnter": "Go back to meeting", + "goodbye.default.header": "You have left the meeting", + "goodbye.default.message": "Thank you for joining!", + "landing.welcome.subtitle": "Power your business with video that transforms customer satisfaction.", + "landing.welcome.title.1": "Upgrade", + "landing.welcome.title.2": "video", + "landing.welcome.title.3": "communication", + "landing.primary.word": "video", + "layout.tooltip.isPinningPresent": "Cannot switch layout while a participant is pinned", + "layout.tooltip.isScreenSharePresent": "Cannot switch layout while screen share is active", + "layout.tooltip.switchToActiveSpeaker": "Switch to Active Speaker layout", + "layout.tooltip.switchToGrid": "Switch to Grid layout", + "mutedAlert.message.forceMuted": "You have been muted by another participant. Click on the mic to unmute yourself.", + "mutedAlert.message.muted": "Are you talking? Your mic is off. Click on the mic to turn it on.", + "participants.list.ariaLabel": "toggle participant list", + "participants.list.close": "Close participant list", + "participants.list.open": "Open participant list", + "participants.maxPin": "You can't pin any more tiles", + "participants.mute.dialog.content": "Mute {{participantName}} for everyone in the call? Only {{participantName}} can unmute themselves.", + "participants.mute.tooltip": "Mute {{participantName}}'s microphone", + "participants.pin": "Pin {{participantName}}", + "participants.pin.video": "Pin {{participantName}}'s video", + "participants.screen": "{{participantName}}'s screen", + "participants.search": "Search participants", + "participants.title": "Participants", + "participants.unpin": "Unpin {{participantName}}", + "participants.unpin.video": "Unpin {{participantName}}'s video", + "publishingErrors.accessDenied.message": "It seems your browser is blocked from accessing your {{device}}. Reset the permission state through your browser's UI.", + "publishingErrors.accessDenied.title": "{{device}} access is denied", + "publishingErrors.blocked.message": "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", + "publishingErrors.blocked.title": "Difficulties joining room", + "subscribingErrors.blocked.message": "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", + "subscribingErrors.blocked.title": "Difficulties joining room", + "recording.start.dialog.content": "Make sure everyone is ready! You can download the recording from the \"Goodbye\" page after you leave the room.", + "recording.start.dialog.title": "Start Recording?", + "recording.start.title": "Start recording", + "recording.stop.dialog.content": "You can download the recording from the \"Goodbye\" page after you leave the room.", + "recording.stop.dialog.title": "Stop Recording?", + "recording.stop.title": "Stop recording", + "recording.consent.dialog.title": "This meeting is being recorded", + "recording.consent.dialog.content": "The host has started recording this meeting. Do you consent to being recorded?", + "recording.consent.dialog.accept": "I consent", + "recording.consent.dialog.decline": "I do not consent", + "recording.consent.goodbye.header": "Recording declined", + "recording.consent.goodbye.message": "You chose not to consent to recording. Please rejoin when recording is stopped or ask the host to disable recording.", + "recording.popup.title": "Recording", + "recording.popup.subtitle": "This meeting is being recorded", + "recording.tooltip.ariaLabel": "video layout", + "room.exit.ariaLabel": "exit", + "room.exit.tooltip": "Exit meeting", + "room.input.helper": "Cannot be empty, contain spaces or special characters", + "room.input.label": "Room name", + "screenSharing.dialog.action": "Start sharing your screen", + "screenSharing.dialog.content": "Looks like there is someone else sharing their screen. If you continue, their screen is no longer going to be shared.", + "screenSharing.dialog.title": "Do you want to share your screen?", + "screenSharing.dialog.hiddenMessage": "You are sharing your screen.", + "screenSharing.title.start": "Start screen share", + "screenSharing.title.stop": "Stop screen share", + "screenSharing.tooltip.ariaLabel": "Start or stop screen share", + "soundTest.start": "Test speakers", + "soundTest.stop": "Stop testing", + "toolbar.tooltip.ariaLabel": "open additional toolbar items menu", + "toolbar.tooltip.title": "Access additional toolbar items", + "unsupportedBrowser.header": "Your browser is not compatible.", + "unsupportedBrowser.message": "Check the list and choose a supported browser.", + "unsupportedBrowser.supported.visitPage": "Visit page", + "unsupportedBrowser.supported.title": "Supported browsers:", + "unknown.device": "Unknown Device", + "user.unknown": "unknown user", + "user.you": "You", + "waitingRoom.unsupportedFeature.tooltip": "Your browser does not support this feature", + "waitingRoom.devices.noDevicesFound": "No devices found", + "waitingRoom.title": "Prepare to join", + "waitingRoom.user.input.title": "Enter your name", + "waitingRoom.user.input.label": "Name", + "waitingRoom.precallNetworkTest.title": "Pre-call network test", + "waitingRoom.precallNetworkTest.subtitle": "Test your device access, network connectivity and call quality before joining", + "waitingRoom.precallNetworkTest.stopTest": "Stop test", + "waitingRoom.precallNetworkTest.stoppedTitle": "Test stopped", + "waitingRoom.precallNetworkTest.stoppedMessage": "You stopped the network test. You can retry when you're ready.", + "waitingRoom.precallNetworkTest.retryTest": "Retry test", + "waitingRoom.precallNetworkTest.audio": "Audio", + "waitingRoom.precallNetworkTest.video": "Video", + "waitingRoom.precallNetworkTest.qualityLabel": "Quality:", + "waitingRoom.precallNetworkTest.error": "Test failed", + "waitingRoom.precallNetworkTest.audioSupported": "Audio is supported", + "waitingRoom.precallNetworkTest.audioNotSupported": "Audio is not supported", + "waitingRoom.precallNetworkTest.videoSupported": "Video is supported", + "waitingRoom.precallNetworkTest.videoNotSupported": "Video is not supported", + "waitingRoom.user.input.error": "Name cannot be empty or contain special characters.", + "zoom.reset": "Reset Zoom", + "zoom.start": "Start Zooming", + "zoom.out": "Zoom out", + "zoom.in": "Zoom in", + "zoom.current": "Current zoom", + "errorPage.title": "Something went wrong", + "errorPage.description": "The application could not start. Please reload the page or contact support if the problem persists.", + "notification.dismiss": "Dismiss notification" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es-MX.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es-MX.json new file mode 100644 index 00000000..a3451e9b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es-MX.json @@ -0,0 +1,318 @@ +{ + "archiveList.archive.createdAt": "Creada: {{createdAt}}", + "archiveList.archive.index": "Grabación {{index}}", + "archiveList.download.tooltip": "Descargar grabación {{id}}", + "archiveList.download": "Descargar", + "archiveList.empty": "La reunión no ha sido grabada", + "archiveList.error.text": "Ocurrió un error al cargar las grabaciones de esta reunión", + "archiveList.error.tooltip": "Esta grabación falló o ya expiró", + "archiveList.label": "Descargar grabaciones", + "archiveList.loading": "Estamos procesando tu grabación", + "archiveList.loading.subtitle": "Espera unos segundos para descargar tu grabación", + "advancedSettings.title": "Ajustes", + "advancedSettings.open": "Abrir ajustes", + "advancedSettings.close": "Cerrar ajustes", + "advancedSettings.ariaLabel": "abrir ajustes", + "advancedSettings.tabs.general": "General", + "advancedSettings.tabs.video": "Video", + "advancedSettings.tabs.audio": "Audio", + "advancedSettings.tabs.statistics": "Estadísticas", + "advancedSettings.general.resetButton": "Restablecer valores predeterminados", + "advancedSettings.general.resetDescription": "Restaura todos los ajustes a sus valores predeterminados", + "advancedSettings.video.bitrate.label": "Tasa de bits", + "advancedSettings.video.bitrate.options.default": "Predeterminada", + "advancedSettings.video.bitrate.options.bw_saver": "Ahorro de ancho de banda", + "advancedSettings.video.bitrate.options.extra_bw_saver": "Ahorro extra de ancho de banda", + "advancedSettings.video.bitrate.options.custom": "Personalizada", + "advancedSettings.video.bitrate.description": "Vonage Video API permite usar perfiles de tasa de bits o definir una tasa de bits máxima de video personalizada.", + "advancedSettings.video.customBitrate.label": "Tasa de bits personalizada", + "advancedSettings.video.customBitrate.description": "Define una tasa de bits máxima de video entre 5 kbps y 10 Mbps.", + "advancedSettings.video.customBitrate.minimum": "5 kbps", + "advancedSettings.video.customBitrate.maximum": "10 Mbps", + "advancedSettings.video.customBitrate.units.lower": "kbps", + "advancedSettings.video.customBitrate.units.higher": "Mbps", + "advancedSettings.video.codec.label": "Codec", + "advancedSettings.video.codec.options.automatic": "Automático", + "advancedSettings.video.codec.options.manual": "Manual", + "advancedSettings.video.codec.description": "El SDK selecciona automáticamente el mejor codec según las condiciones de red y las capacidades de los participantes. Los cambios se aplican en la próxima llamada.", + "advancedSettings.video.codec.priority.label": "Prioridad de codec", + "advancedSettings.video.codec.priority.description": "Arrastra y suelta para elegir el orden de preferencia de los codecs, de primero a último.", + "advancedSettings.video.codec.priority.options.vp9": "VP9", + "advancedSettings.video.codec.priority.options.vp8": "VP8", + "advancedSettings.video.codec.priority.options.h264": "H.264", + "advancedSettings.video.frameRate.label": "Fotogramas por segundo", + "advancedSettings.video.frameRate.options.30": "30 FPS", + "advancedSettings.video.frameRate.options.15": "15 FPS", + "advancedSettings.video.frameRate.options.7": "7 FPS", + "advancedSettings.video.frameRate.options.1": "1 FPS", + "advancedSettings.video.resolution.label": "Resolución", + "advancedSettings.video.error.resolutionNotSupported": "La resolución seleccionada no es compatible con tu dispositivo.", + "advancedSettings.video.error.frameRateNotSupported": "La tasa de fotogramas seleccionada no es compatible con tu dispositivo.", + "advancedSettings.video.error.bitrateNotSupported": "No se pudo aplicar la tasa de bits seleccionada.", + "advancedSettings.audio.bitrate.label": "Tasa de bits de audio", + "advancedSettings.audio.bitrate.description": "Elige automáticamente el valor predeterminado del SDK, o personalizada para definir la tasa de bits máxima de codificación de audio enviada a la sesión.", + "advancedSettings.audio.bitrate.options.automatic": "Automática", + "advancedSettings.audio.bitrate.options.custom": "Personalizada", + "advancedSettings.audio.bitrate.customLabel": "Tasa de bits de audio personalizada", + "advancedSettings.audio.bitrate.minimum": "6 kbps", + "advancedSettings.audio.bitrate.maximum": "510 kbps", + "advancedSettings.audio.bitrate.currentValue": "{{value}} kbps", + "advancedSettings.audio.enableDtx.label": "Activar Opus DTX", + "advancedSettings.audio.enableDtx.description": "Cuando está activado, la transmisión discontinua de Opus reduce el ancho de banda de audio durante los silencios.", + "advancedSettings.audio.publisherAudioFallback.label": "Audio de respaldo del publicador", + "advancedSettings.audio.publisherAudioFallback.description": "Cuando está habilitado, tu video deja de renderizarse en otros dispositivos durante condiciones de red deficientes para preservar el audio.", + "advancedSettings.audio.subscriberAudioFallback.label": "Audio de respaldo del suscriptor", + "advancedSettings.audio.subscriberAudioFallback.description": "Cuando está habilitado, recibes solo audio de otros participantes durante condiciones de red deficientes.", + "advancedSettings.audio.nextCallWarningWaitingRoom": "Los cambios se aplicarán al unirte a la llamada.", + "advancedSettings.audio.nextCallWarningMeetingRoom": "Los cambios se aplicarán en tu próxima llamada o al refrescar.", + "advancedSettings.statistics.collection.enablePublisher.label": "Habilitar estimación de ancho de banda del emisor", + "advancedSettings.statistics.collection.enablePublisher.description": "Cuando está habilitado, muestra el ancho de banda de salida estimado para el publicador local.", + "advancedSettings.statistics.groups.publisher": "Publicador", + "advancedSettings.statistics.groups.subscribers": "Suscriptores", + "advancedSettings.statistics.empty": "Todavía no hay estadísticas disponibles.", + "advancedSettings.statistics.sections.audio": "Audio", + "advancedSettings.statistics.sections.video": "Video", + "advancedSettings.statistics.metrics.resolution": "Resolución", + "advancedSettings.statistics.metrics.frameRate": "Frecuencia de cuadros", + "advancedSettings.statistics.metrics.decodedFrameRate": "Frecuencia de cuadros decodificada", + "advancedSettings.statistics.metrics.bitrate": "Tasa de bits", + "advancedSettings.statistics.metrics.packetLoss": "Pérdida de paquetes", + "advancedSettings.statistics.metrics.freezeCount": "Número de congelaciones", + "advancedSettings.statistics.metrics.totalFreezesDuration": "Duración total de congelaciones", + "advancedSettings.statistics.metrics.codec": "Codec", + "advancedSettings.statistics.metrics.videoLayer": "Capa {{index}} ({{codec}})", + "advancedSettings.statistics.metrics.packetsSent": "Paquetes enviados", + "advancedSettings.statistics.metrics.packetsLostSent": "Paquetes perdidos (envío)", + "advancedSettings.statistics.metrics.bytesSent": "Bytes enviados", + "advancedSettings.statistics.metrics.packetsReceived": "Paquetes recibidos", + "advancedSettings.statistics.metrics.packetsLostReceived": "Paquetes perdidos (recepción)", + "advancedSettings.statistics.metrics.bytesReceived": "Bytes recibidos", + "advancedSettings.statistics.metrics.estimatedBandwidth": "Ancho de banda estimado", + "advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth": "Ancho de banda estimado del publicador remoto", + "backgroundEffects.limit": "Llegaste al límite máximo de imágenes personalizadas", + "backgroundEffects.title": "Efectos de vídeo", + "backgroundEffects.addBackground": "Agregar fondo", + "backgroundEffects.video.disabled": "Tu cámara está apagada.", + "backgroundEffects.invalidFileType": "Solo se permiten imágenes JPG, PNG, GIF o BMP.", + "backgroundEffects.fileTooLarge": "La imagen debe pesar menos de {{maxSize}}MB.", + "backgroundEffects.processingError": "No se pudo procesar la imagen subida.", + "backgroundEffects.storageError": "No se pudo guardar la imagen.", + "backgroundEffects.maxSize": "Máx {{maxSize}}MB", + "backgroundEffects.removeBackground": "Quitar fondo", + "backgroundEffects.slightBlur": "Desenfoque ligero del fondo", + "backgroundEffects.strongBlur": "Desenfoque fuerte del fondo", + "backgroundEffects.yourBackground": "Tu fondo", + "backgroundEffects.deleteTooltipInUse": "No puedes eliminar este fondo mientras esté en uso", + "backgroundEffects.deleteTooltip": "Eliminar fondo", + "backgroundEffects.deleteAriaLabel": "Eliminar fondo personalizado", + "backgroundEffects.backgrounds.bookshelfRoom": "Cuarto con librero", + "backgroundEffects.backgrounds.busyRoom": "Sala ocupada", + "backgroundEffects.backgrounds.duneView": "Vista de dunas", + "backgroundEffects.backgrounds.hogwarts": "Hogwarts", + "backgroundEffects.backgrounds.library": "Biblioteca", + "backgroundEffects.backgrounds.newYork": "Nueva York", + "backgroundEffects.backgrounds.plane": "Avión", + "backgroundEffects.backgrounds.whiteRoom": "Cuarto blanco", + "button.apply": "Aplicar", + "button.camera": "Cámara", + "button.cancel": "Cancelar", + "button.close": "Cerrar", + "button.createRoom": "Crear una nueva sala", + "button.startNewRoom": "Iniciar una nueva videoconferencia", + "button.joinExistingMeeting": "Unirse a una reunión existente", + "button.joinWaitingRoom": "Unirse a la sala de espera", + "button.join": "Unirse a la reunión", + "button.mute": "Silenciar", + "button.send": "Enviar", + "button.speaker": "Altavoz", + "captions.ariaLabel": "botón de subtítulos", + "captions.disable": "Desactivar subtítulos", + "captions.enable": "Activar subtítulos", + "captions.errors": "Error en subtítulos: {{captionsErrorResponse}}", + "chat.ariaLabel": "abrir/cerrar chat", + "chat.close": "Cerrar chat", + "chat.copied": "Copiado", + "chat.copy": "Copiar al portapapeles", + "chat.input.placeholder": "Escribe un mensaje", + "chat.meetingUrl": "URL de la reunión:", + "chat.open": "Abrir chat", + "chat.title": "Chat", + "common.or": "o", + "connectionAlert.quality.message": "Revisa tu conexión. Tu video puede desactivarse para mejorar la experiencia.", + "connectionAlert.quality.title": "Problema de calidad de video", + "connectionAlert.reconnecting.message": "Revisa tu conexión de internet", + "connectionAlert.reconnecting.title": "Conexión perdida", + "deviceAccessAlert.askDeviceMessage": "Para entrar a la sala de video, tu navegador pedirá acceso a la cámara y el micrófono.", + "deviceAccessAlert.deniedDeviceMessage": "Parece que tu navegador tiene bloqueado el acceso a tu cámara o micrófono. Restablece los permisos desde la configuración del navegador.", + "deviceAccessAlert.imageAlt": "Diálogo de acceso", + "devices.audio.ariaLabel": "menú de dispositivos de audio", + "devices.audio.defaultLabel": "Predeterminado del sistema", + "devices.audio.disable": "Apagar micrófono", + "devices.audio.enable": "Encender micrófono o mantén presionada la barra espaciadora para activar temporalmente el micrófono", + "devices.audio.microphone.ariaLabel": "activar/desactivar audio", + "devices.audio.microphone.full": "Micrófono", + "devices.audio.microphone.short": "Mic", + "devices.audio.microphone.state.on": "Encender micrófono", + "devices.audio.microphone.state.off": "Apagar micrófono", + "devices.audio.noiseSuppression": "Supresión avanzada de ruido", + "devices.audio.speakers.full": "Bocinas", + "devices.buttons.ariaLabel": "botón dividido", + "devices.settings.ariaLabel": "configuración de dispositivos", + "devices.select.unknownDevice": "dispositivo", + "devices.select.errorContext": "No se puede seleccionar {{device}}. Verifica los permisos del dispositivo e inténtalo de nuevo.", + "devices.select.accessDenied": "Se negó el acceso al {{device}}. Verifica los permisos de tu navegador.", + "devices.select.trackUnavailable": "El {{device}} no está disponible en este momento. Verifica la conexión de tu dispositivo.", + "devices.video.ariaLabel": "menú de dispositivos de video", + "devices.video.blur.ariaLabel": "Activar/desactivar desenfoque de fondo", + "devices.video.blur.label": "Desenfocar fondo", + "devices.video.camera.ariaLabel": "activar/desactivar video", + "devices.video.camera.switch": "Cambiar cámara", + "devices.video.camera.state.on": "Encender cámara", + "devices.video.camera.state.off": "Apagar cámara", + "devices.video.camera.full": "Cámara", + "devices.video.camera.front": "frontal", + "devices.video.camera.rear": "trasera", + "devices.video.camera.switchError": "No se pudo cambiar a la cámara {{facingMode}}", + "devices.video.camera.accessError": "No se puede usar la cámara {{facingMode}}. Verifica tus permisos de cámara e intenta de nuevo.", + "devices.video.camera.noTrackError": "La cámara {{facingMode}} no se inició correctamente. Intenta con otra cámara.", + "devices.video.camera.noDeviceError": "No se encontró ninguna cámara {{facingMode}}", + "devices.video.camera.noCurrentDeviceError": "Ninguna cámara actualmente seleccionada. Selecciona una cámara primero.", + "devices.video.camera.selectedDevice": "Cámara seleccionada: {{deviceName}}", + "devices.video.disable": "Apagar video", + "devices.video.enable": "Encender video", + "emoji.ariaLabel": "abrir menú de emojis", + "emoji.tooltip": "Exprésate", + "errors.unknown": "Ocurrió un error desconocido", + "feedbackForm.ariaLabel": "abrir/cerrar formulario de reporte", + "feedbackForm.close": "Cerrar formulario", + "feedbackForm.disclaiamer.label": "Por favor no incluyas datos sensibles.", + "feedbackForm.disclaiamer.screenshot": "Una captura de pantalla nos ayuda a entender mejor el problema (opcional).", + "feedbackForm.field.issue.label": "Describe el problema", + "feedbackForm.field.issue.type": "Descripción", + "feedbackForm.field.name.label": "Tu nombre", + "feedbackForm.field.name.type": "Nombre", + "feedbackForm.field.title.label": "Cuando pasó el problema, ¿qué estabas haciendo?", + "feedbackForm.field.title.type": "Título", + "feedbackForm.helperText.error": "{{field}} es obligatorio y debe tener menos de {{limit}} caracteres", + "feedbackForm.open": "Abrir formulario de reporte", + "feedbackForm.submitted.content": "¡Gracias! Tu aporte nos ayuda a mejorar la aplicación y resolver problemas.", + "feedbackForm.submitted.error": "Algo salió mal. Intenta de nuevo.", + "feedbackForm.submitted.thanks": "¡Gracias por tus comentarios!", + "feedbackForm.submitted.track": "Puedes dar seguimiento aquí.", + "feedbackForm.title": "Reportar problema", + "filePicker.addScreenshot": "Agregar captura", + "filePicker.attachedScreenshot": "Captura adjunta", + "filePicker.capture": "Tomar captura de pantalla", + "filePicker.delete": "Eliminar captura", + "filePicker.sizeLimit": "El tamaño máximo es 20MB. Sube otro archivo.", + "footer.github.title": "Aplicación de referencia de video de Vonage", + "imageStorage.imagesExceedSize": "Las imágenes son muy grandes para guardar (~4MB máx).", + "imageStorage.storageFailure": "No se pudieron guardar las imágenes en el almacenamiento local.", + "imageStorage.duplicateImage": "Esta imagen ya fue agregada.", + "imageStorage.fileReadError": "No se pudo leer el archivo de imagen.", + "imageStorage.invalidExtension": "Extensión de imagen no válida.", + "imageStorage.invalidUrl": "URL de imagen no válida.", + "imageStorage.convertError": "No se pudo convertir la imagen.", + "imageStorage.loadError": "No se pudo cargar la imagen.", + "githubTooltip": "Visita nuestro repositorio en GitHub", + "goodBye.title": "Regresando a la sala", + "goodBye.back": "Ver página de inicio", + "goodBye.reEnter": "Volver a la reunión", + "goodbye.default.header": "Has salido de la reunión", + "goodbye.default.message": "¡Gracias por unirte!", + "landing.welcome.subtitle": "Impulsa tu negocio con videos que transforman la satisfacción del cliente.", + "landing.welcome.title.1": "Mejora", + "landing.welcome.title.2": "la comunicación", + "landing.welcome.title.3": "por video", + "landing.primary.word": "video", + "layout.tooltip.isPinningPresent": "No se puede cambiar el diseño mientras alguien esté fijado", + "layout.tooltip.isScreenSharePresent": "No se puede cambiar el diseño mientras se comparte pantalla", + "layout.tooltip.switchToActiveSpeaker": "Cambiar a vista de orador activo", + "layout.tooltip.switchToGrid": "Cambiar a vista de cuadrícula", + "mutedAlert.message.forceMuted": "Otro participante te silenció. Haz clic en el micrófono para activarlo.", + "mutedAlert.message.muted": "¿Estás hablando? Tu micrófono está apagado. Haz clic en el micrófono para encenderlo.", + "participants.list.ariaLabel": "abrir/cerrar lista de participantes", + "participants.list.close": "Cerrar lista de participantes", + "participants.list.open": "Abrir lista de participantes", + "participants.maxPin": "No puedes fijar más mosaicos", + "participants.mute.dialog.content": "¿Silenciar a {{participantName}} para todos en la llamada? Solo {{participantName}} puede encenderse de nuevo.", + "participants.mute.tooltip": "Silenciar micrófono de {{participantName}}", + "participants.pin": "Fijar a {{participantName}}", + "participants.pin.video": "Fijar video de {{participantName}}", + "participants.screen": "Pantalla de {{participantName}}", + "participants.search": "Buscar participantes", + "participants.title": "Participantes", + "participants.unpin": "Quitar fijación de {{participantName}}", + "participants.unpin.video": "Quitar fijación de video de {{participantName}}", + "publishingErrors.accessDenied.message": "Parece que tu navegador bloqueó el acceso a tu {{device}}. Restablece los permisos desde la configuración.", + "publishingErrors.accessDenied.title": "Acceso a {{device}} denegado", + "publishingErrors.blocked.message": "No pudimos conectarte con la sala. Revisa tu conexión e inténtalo de nuevo.", + "publishingErrors.blocked.title": "Problemas para entrar a la sala", + "subscribingErrors.blocked.message": "No pudimos conectarte con la sala. Revisa tu conexión e inténtalo de nuevo.", + "subscribingErrors.blocked.title": "Problemas para entrar a la sala", + "recording.start.dialog.content": "¡Asegúrate que todos estén listos! Podrás descargar la grabación en la página de \"Despedida\" al salir.", + "recording.start.dialog.title": "¿Iniciar grabación?", + "recording.start.title": "Iniciar grabación", + "recording.stop.dialog.content": "Podrás descargar la grabación en la página de \"Despedida\" al salir.", + "recording.stop.dialog.title": "¿Detener grabación?", + "recording.stop.title": "Detener grabación", + "recording.consent.dialog.title": "Esta reunión se está grabando", + "recording.consent.dialog.content": "El anfitrión inició la grabación de esta reunión. ¿Das tu consentimiento para ser grabado?", + "recording.consent.dialog.accept": "Sí, doy mi consentimiento", + "recording.consent.dialog.decline": "No doy mi consentimiento", + "recording.consent.goodbye.header": "Grabación rechazada", + "recording.consent.goodbye.message": "Decidiste no dar tu consentimiento para la grabación. Vuelve a unirte cuando la grabación se detenga o pide al anfitrión que la desactive.", + "recording.popup.title": "Grabación", + "recording.popup.subtitle": "Esta reunión se está grabando", + "recording.tooltip.ariaLabel": "diseño de video", + "room.exit.ariaLabel": "salir", + "room.exit.tooltip": "Salir de la reunión", + "room.input.helper": "No se permite dejar el campo vacío, espacios ni caracteres especiales", + "room.input.label": "Nombre de sala", + "screenSharing.dialog.action": "Comenzar a compartir pantalla", + "screenSharing.dialog.content": "Alguien más está compartiendo pantalla. Si continúas, su pantalla dejará de mostrarse.", + "screenSharing.dialog.title": "¿Quieres compartir tu pantalla?", + "screenSharing.dialog.hiddenMessage": "Estás compartiendo tu pantalla.", + "screenSharing.title.start": "Iniciar compartir pantalla", + "screenSharing.title.stop": "Dejar de compartir pantalla", + "screenSharing.tooltip.ariaLabel": "Iniciar o detener compartir pantalla", + "soundTest.start": "Probar bocinas", + "soundTest.stop": "Detener prueba", + "toolbar.tooltip.ariaLabel": "abrir menú extra de la barra", + "toolbar.tooltip.title": "Acceder a opciones adicionales", + "unsupportedBrowser.header": "Tu navegador no es compatible", + "unsupportedBrowser.message": "Revisa la lista y elige un navegador compatible.", + "unsupportedBrowser.supported.visitPage": "Visitar la página", + "unsupportedBrowser.supported.title": "Navegadores compatibles:", + "unknown.device": "Dispositivo desconocido", + "user.unknown": "usuario desconocido", + "user.you": "Tú", + "waitingRoom.unsupportedFeature.tooltip": "Tu navegador no soporta esta funcionalidad", + "waitingRoom.devices.noDevicesFound": "No se han encontrado dispositivos", + "waitingRoom.title": "Prepárate para unirte", + "waitingRoom.user.input.title": "Escribe tu nombre", + "waitingRoom.user.input.label": "Nombre", + "waitingRoom.precallNetworkTest.title": "Prueba de red antes de la llamada", + "waitingRoom.precallNetworkTest.subtitle": "Prueba el acceso a tus dispositivos, la conectividad de red y la calidad de la llamada antes de unirte", + "waitingRoom.precallNetworkTest.stopTest": "Detener prueba", + "waitingRoom.precallNetworkTest.stoppedTitle": "Prueba detenida", + "waitingRoom.precallNetworkTest.stoppedMessage": "Has detenido la prueba de red. Puedes volver a intentarlo cuando estés listo.", + "waitingRoom.precallNetworkTest.retryTest": "Reintentar prueba", + "waitingRoom.precallNetworkTest.audio": "Audio", + "waitingRoom.precallNetworkTest.video": "Video", + "waitingRoom.precallNetworkTest.qualityLabel": "Calidad:", + "waitingRoom.precallNetworkTest.error": "La prueba falló", + "waitingRoom.precallNetworkTest.audioSupported": "El audio es compatible", + "waitingRoom.precallNetworkTest.audioNotSupported": "El audio no es compatible", + "waitingRoom.precallNetworkTest.videoSupported": "El video es compatible", + "waitingRoom.precallNetworkTest.videoNotSupported": "El video no es compatible", + "waitingRoom.user.input.error": "El nombre no puede estar vacío ni contener caracteres especiales.", + "zoom.reset": "Restablecer zoom", + "zoom.start": "Iniciar zoom", + "zoom.out": "Alejar zoom", + "zoom.in": "Acercar zoom", + "zoom.current": "Zoom actual", + "errorPage.title": "Algo salió mal", + "errorPage.description": "La aplicación no pudo iniciarse. Recarga la página o contacta con soporte si el problema persiste.", + "notification.dismiss": "Descartar notificación" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es.json new file mode 100644 index 00000000..272c3949 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/es.json @@ -0,0 +1,318 @@ +{ + "archiveList.archive.createdAt": "Creada: {{createdAt}}", + "archiveList.archive.index": "Grabación {{index}}", + "archiveList.download.tooltip": "Descargar grabación {{id}}", + "archiveList.download": "Descargar", + "archiveList.empty": "La reunión no ha sido grabada", + "archiveList.error.text": "Hubo un error al cargar las grabaciones de esta reunión", + "archiveList.error.tooltip": "Esta grabación falló o ha caducado", + "archiveList.label": "Descargar grabaciones", + "archiveList.loading": "Estamos procesando tu grabación", + "archiveList.loading.subtitle": "Espera unos segundos para descargar tu grabación", + "advancedSettings.title": "Ajustes", + "advancedSettings.open": "Abrir ajustes", + "advancedSettings.close": "Cerrar ajustes", + "advancedSettings.ariaLabel": "abrir ajustes", + "advancedSettings.tabs.general": "General", + "advancedSettings.tabs.video": "Vídeo", + "advancedSettings.tabs.audio": "Audio", + "advancedSettings.tabs.statistics": "Estadísticas", + "advancedSettings.general.resetButton": "Restablecer valores predeterminados", + "advancedSettings.general.resetDescription": "Restaura todos los ajustes a sus valores predeterminados", + "advancedSettings.video.bitrate.label": "Tasa de bits", + "advancedSettings.video.bitrate.options.default": "Predeterminada", + "advancedSettings.video.bitrate.options.bw_saver": "Ahorro de ancho de banda", + "advancedSettings.video.bitrate.options.extra_bw_saver": "Ahorro extra de ancho de banda", + "advancedSettings.video.bitrate.options.custom": "Personalizada", + "advancedSettings.video.bitrate.description": "Vonage Video API permite usar perfiles de tasa de bits o definir una tasa de bits máxima de video personalizada.", + "advancedSettings.video.customBitrate.label": "Tasa de bits personalizada", + "advancedSettings.video.customBitrate.description": "Define una tasa de bits máxima de video entre 5 kbps y 10 Mbps.", + "advancedSettings.video.customBitrate.minimum": "5 kbps", + "advancedSettings.video.customBitrate.maximum": "10 Mbps", + "advancedSettings.video.customBitrate.units.lower": "kbps", + "advancedSettings.video.customBitrate.units.higher": "Mbps", + "advancedSettings.video.codec.label": "Codec", + "advancedSettings.video.codec.options.automatic": "Automático", + "advancedSettings.video.codec.options.manual": "Manual", + "advancedSettings.video.codec.description": "El SDK selecciona automáticamente el mejor codec según las condiciones de red y las capacidades de los participantes. Los cambios se aplican en la próxima llamada.", + "advancedSettings.video.codec.priority.label": "Prioridad de codec", + "advancedSettings.video.codec.priority.description": "Arrastra y suelta para elegir el orden de preferencia de los codecs, de primero a último.", + "advancedSettings.video.codec.priority.options.vp9": "VP9", + "advancedSettings.video.codec.priority.options.vp8": "VP8", + "advancedSettings.video.codec.priority.options.h264": "H.264", + "advancedSettings.video.frameRate.label": "Fotogramas por segundo", + "advancedSettings.video.frameRate.options.30": "30 FPS", + "advancedSettings.video.frameRate.options.15": "15 FPS", + "advancedSettings.video.frameRate.options.7": "7 FPS", + "advancedSettings.video.frameRate.options.1": "1 FPS", + "advancedSettings.video.resolution.label": "Resolución", + "advancedSettings.video.error.resolutionNotSupported": "La resolución seleccionada no es compatible con tu dispositivo.", + "advancedSettings.video.error.frameRateNotSupported": "La tasa de fotogramas seleccionada no es compatible con tu dispositivo.", + "advancedSettings.video.error.bitrateNotSupported": "No se pudo aplicar la tasa de bits seleccionada.", + "advancedSettings.audio.bitrate.label": "Tasa de bits de audio", + "advancedSettings.audio.bitrate.description": "Elige automáticamente el valor predeterminado del SDK, o personalizada para definir la tasa de bits máxima de codificación de audio enviada a la sesión.", + "advancedSettings.audio.bitrate.options.automatic": "Automática", + "advancedSettings.audio.bitrate.options.custom": "Personalizada", + "advancedSettings.audio.bitrate.customLabel": "Tasa de bits de audio personalizada", + "advancedSettings.audio.bitrate.minimum": "6 kbps", + "advancedSettings.audio.bitrate.maximum": "510 kbps", + "advancedSettings.audio.bitrate.currentValue": "{{value}} kbps", + "advancedSettings.audio.enableDtx.label": "Activar Opus DTX", + "advancedSettings.audio.enableDtx.description": "Cuando está activado, la transmisión discontinua de Opus reduce el ancho de banda de audio durante los silencios.", + "advancedSettings.audio.publisherAudioFallback.label": "Audio de respaldo del publicador", + "advancedSettings.audio.publisherAudioFallback.description": "Cuando está habilitado, tu video deja de renderizarse en otros dispositivos durante condiciones de red deficientes para preservar el audio.", + "advancedSettings.audio.subscriberAudioFallback.label": "Audio de respaldo del suscriptor", + "advancedSettings.audio.subscriberAudioFallback.description": "Cuando está habilitado, recibes solo audio de otros participantes durante condiciones de red deficientes.", + "advancedSettings.audio.nextCallWarningWaitingRoom": "Los cambios se aplicarán al unirte a la llamada.", + "advancedSettings.audio.nextCallWarningMeetingRoom": "Los cambios se aplicarán en tu próxima llamada o al refrescar.", + "advancedSettings.statistics.collection.enablePublisher.label": "Habilitar estimación de ancho de banda del emisor", + "advancedSettings.statistics.collection.enablePublisher.description": "Cuando está habilitado, muestra el ancho de banda de salida estimado para el publicador local.", + "advancedSettings.statistics.groups.publisher": "Publicador", + "advancedSettings.statistics.groups.subscribers": "Suscriptores", + "advancedSettings.statistics.empty": "Todavía no hay estadísticas disponibles.", + "advancedSettings.statistics.sections.audio": "Audio", + "advancedSettings.statistics.sections.video": "Vídeo", + "advancedSettings.statistics.metrics.resolution": "Resolución", + "advancedSettings.statistics.metrics.frameRate": "Frecuencia de cuadros", + "advancedSettings.statistics.metrics.decodedFrameRate": "Frecuencia de cuadros decodificada", + "advancedSettings.statistics.metrics.bitrate": "Tasa de bits", + "advancedSettings.statistics.metrics.packetLoss": "Pérdida de paquetes", + "advancedSettings.statistics.metrics.freezeCount": "Número de congelaciones", + "advancedSettings.statistics.metrics.totalFreezesDuration": "Duración total de congelaciones", + "advancedSettings.statistics.metrics.codec": "Codec", + "advancedSettings.statistics.metrics.videoLayer": "Capa {{index}} ({{codec}})", + "advancedSettings.statistics.metrics.packetsSent": "Paquetes enviados", + "advancedSettings.statistics.metrics.packetsLostSent": "Paquetes perdidos (envío)", + "advancedSettings.statistics.metrics.bytesSent": "Bytes enviados", + "advancedSettings.statistics.metrics.packetsReceived": "Paquetes recibidos", + "advancedSettings.statistics.metrics.packetsLostReceived": "Paquetes perdidos (recepción)", + "advancedSettings.statistics.metrics.bytesReceived": "Bytes recibidos", + "advancedSettings.statistics.metrics.estimatedBandwidth": "Ancho de banda estimado", + "advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth": "Ancho de banda estimado del publicador remoto", + "backgroundEffects.limit": "Has alcanzado el límite máximo de imágenes personalizadas", + "backgroundEffects.title": "Efectos de vídeo", + "backgroundEffects.addBackground": "Añadir fondo", + "backgroundEffects.video.disabled": "Tu cámara está apagada.", + "backgroundEffects.invalidFileType": "Solo se permiten imágenes JPG, PNG, GIF o BMP.", + "backgroundEffects.fileTooLarge": "La imagen debe ser menor a {{maxSize}}MB.", + "backgroundEffects.processingError": "Error al procesar la imagen subida.", + "backgroundEffects.storageError": "Error al guardar la imagen.", + "backgroundEffects.maxSize": "Máx {{maxSize}}MB", + "backgroundEffects.removeBackground": "Quitar fondo", + "backgroundEffects.slightBlur": "Desenfoque ligero del fondo", + "backgroundEffects.strongBlur": "Desenfoque fuerte del fondo", + "backgroundEffects.yourBackground": "Tu fondo", + "backgroundEffects.deleteTooltipInUse": "No puedes eliminar este fondo mientras esté en uso", + "backgroundEffects.deleteTooltip": "Eliminar fondo", + "backgroundEffects.deleteAriaLabel": "Eliminar fondo personalizado", + "backgroundEffects.backgrounds.bookshelfRoom": "Habitación con estantería", + "backgroundEffects.backgrounds.busyRoom": "Habitación ocupada", + "backgroundEffects.backgrounds.duneView": "Vista de dunas", + "backgroundEffects.backgrounds.hogwarts": "Hogwarts", + "backgroundEffects.backgrounds.library": "Biblioteca", + "backgroundEffects.backgrounds.newYork": "Nueva York", + "backgroundEffects.backgrounds.plane": "Avión", + "backgroundEffects.backgrounds.whiteRoom": "Habitación blanca", + "button.apply": "Aplicar", + "button.camera": "Cámara", + "button.cancel": "Cancelar", + "button.close": "Cerrar", + "button.createRoom": "Crear una nueva sala", + "button.startNewRoom": "Iniciar una nueva videoconferencia", + "button.joinExistingMeeting": "Unirse a una reunión existente", + "button.joinWaitingRoom": "Unirse a la sala de espera", + "button.join": "Unirse a la reunión", + "button.mute": "Silenciar", + "button.send": "Enviar", + "button.speaker": "Altavoz", + "captions.ariaLabel": "botón de subtítulos", + "captions.disable": "Desactivar subtítulos", + "captions.enable": "Activar subtítulos", + "captions.errors": "Error de subtítulos: {{captionsErrorResponse}}", + "chat.ariaLabel": "alternar chat", + "chat.close": "Cerrar chat", + "chat.copied": "Copiado", + "chat.copy": "Copiar al portapapeles", + "chat.input.placeholder": "Enviar un mensaje", + "chat.meetingUrl": "URL de la reunión:", + "chat.open": "Abrir chat", + "chat.title": "Chat", + "common.or": "o", + "connectionAlert.quality.message": "Por favor verifica tu conectividad. Tu video puede estar deshabilitado para mejorar la experiencia.", + "connectionAlert.quality.title": "Problema de calidad de video", + "connectionAlert.reconnecting.message": "Por favor verifica tu conexión de red", + "connectionAlert.reconnecting.title": "Conexión perdida", + "deviceAccessAlert.askDeviceMessage": "Para unirte a la sala de video, tu navegador solicitará acceso a tu cámara y micrófono.", + "deviceAccessAlert.deniedDeviceMessage": "Parece que tu navegador tiene bloqueado el acceso a tu cámara y/o micrófono. Restablece los permisos desde la interfaz del navegador.", + "deviceAccessAlert.imageAlt": "Diálogo de acceso", + "devices.audio.ariaLabel": "desplegable de dispositivos de audio", + "devices.audio.defaultLabel": "Predeterminado del sistema", + "devices.audio.disable": "Desactivar micrófono", + "devices.audio.enable": "Activar micrófono o mantén presionada la barra espaciadora para activar temporalmente el micrófono", + "devices.audio.microphone.ariaLabel": "alternar audio", + "devices.audio.microphone.full": "Micrófono", + "devices.audio.microphone.short": "Mic", + "devices.audio.microphone.state.on": "Activar micrófono", + "devices.audio.microphone.state.off": "Desactivar micrófono", + "devices.audio.noiseSuppression": "Supresión avanzada de ruido", + "devices.audio.speakers.full": "Altavoces", + "devices.buttons.ariaLabel": "botón dividido", + "devices.settings.ariaLabel": "configuración de dispositivos", + "devices.select.unknownDevice": "dispositivo", + "devices.select.errorContext": "No se puede seleccionar {{device}}. Comprueba los permisos del dispositivo e inténtalo de nuevo.", + "devices.select.accessDenied": "Se denegó el acceso al {{device}}. Comprueba los permisos de tu navegador.", + "devices.select.trackUnavailable": "El {{device}} no está disponible en este momento. Comprueba la conexión de tu dispositivo.", + "devices.video.ariaLabel": "desplegable de dispositivos de video", + "devices.video.blur.ariaLabel": "Alternar desenfoque de fondo", + "devices.video.blur.label": "Desenfoca tu fondo", + "devices.video.camera.ariaLabel": "alternar video", + "devices.video.camera.switch": "Cambiar cámara", + "devices.video.camera.state.on": "Activar cámara", + "devices.video.camera.state.off": "Desactivar cámara", + "devices.video.camera.full": "Cámara", + "devices.video.camera.front": "frontal", + "devices.video.camera.rear": "trasera", + "devices.video.camera.switchError": "No se pudo cambiar a la cámara {{facingMode}}", + "devices.video.camera.accessError": "No se puede usar la cámara {{facingMode}}. Verifica tus permisos de cámara e intenta de nuevo.", + "devices.video.camera.noTrackError": "La cámara {{facingMode}} no se inició correctamente. Intenta con otra cámara.", + "devices.video.camera.noDeviceError": "No se encontró ninguna cámara {{facingMode}}", + "devices.video.camera.noCurrentDeviceError": "Ninguna cámara actualmente seleccionada. Selecciona una cámara primero.", + "devices.video.camera.selectedDevice": "Cámara seleccionada: {{deviceName}}", + "devices.video.disable": "Desactivar video", + "devices.video.enable": "Activar video", + "emoji.ariaLabel": "abrir menú de emojis", + "emoji.tooltip": "Exprésate", + "errors.unknown": "Ocurrió un error desconocido", + "feedbackForm.ariaLabel": "alternar formulario de reporte", + "feedbackForm.close": "Cerrar formulario de reporte", + "feedbackForm.disclaiamer.label": "Por favor no incluyas información sensible.", + "feedbackForm.disclaiamer.screenshot": "Una captura de pantalla nos ayudará a entender mejor el problema. (opcional)", + "feedbackForm.field.issue.label": "Describe tu problema", + "feedbackForm.field.issue.type": "Descripción", + "feedbackForm.field.name.label": "Dinos tu nombre", + "feedbackForm.field.name.type": "Tu nombre", + "feedbackForm.field.title.label": "Cuando notaste este problema, ¿qué estabas intentando hacer?", + "feedbackForm.field.title.type": "Título", + "feedbackForm.helperText.error": "{{field}} es obligatorio y debe tener menos de {{limit}} caracteres", + "feedbackForm.open": "Abrir formulario de reporte", + "feedbackForm.submitted.content": "Agradecemos mucho tu aporte, nos ayuda a mejorar la aplicación y resolver problemas.", + "feedbackForm.submitted.error": "Algo salió mal. Por favor intenta de nuevo.", + "feedbackForm.submitted.thanks": "¡Gracias por tu feedback!", + "feedbackForm.submitted.track": "Sigue tu progreso aquí.", + "feedbackForm.title": "Reportar problema", + "filePicker.addScreenshot": "Agregar captura", + "filePicker.attachedScreenshot": "Captura adjunta", + "filePicker.capture": "Capturar pantalla", + "filePicker.delete": "Eliminar captura", + "filePicker.sizeLimit": "El tamaño máximo de carga es 20MB. Por favor sube otro archivo.", + "footer.github.title": "Aplicación de referencia de video de Vonage", + "imageStorage.imagesExceedSize": "Las imágenes son demasiado grandes para almacenar (~4MB máx).", + "imageStorage.storageFailure": "Error al guardar las imágenes en el almacenamiento local.", + "imageStorage.duplicateImage": "Esta imagen ya ha sido agregada.", + "imageStorage.fileReadError": "Error al leer el archivo de imagen.", + "imageStorage.invalidExtension": "Extensión de imagen no válida.", + "imageStorage.invalidUrl": "URL de imagen no válida.", + "imageStorage.convertError": "No se pudo convertir la imagen.", + "imageStorage.loadError": "No se pudo cargar la imagen.", + "githubTooltip": "Visita nuestro repositorio en GitHub", + "goodBye.title": "Volviendo a la sala", + "goodBye.back": "Ver página de inicio", + "goodBye.reEnter": "Volver a la reunión", + "goodbye.default.header": "Has salido de la reunión", + "goodbye.default.message": "¡Gracias por unirte!", + "landing.welcome.subtitle": "Impulsa tu negocio con vídeos que transforman la satisfacción del cliente.", + "landing.welcome.title.1": "Mejora", + "landing.welcome.title.2": "la comunicación", + "landing.welcome.title.3": "por vídeo", + "landing.primary.word": "vídeo", + "layout.tooltip.isPinningPresent": "No se puede cambiar el diseño mientras un participante está fijado", + "layout.tooltip.isScreenSharePresent": "No se puede cambiar el diseño mientras se comparte pantalla", + "layout.tooltip.switchToActiveSpeaker": "Cambiar al diseño de orador activo", + "layout.tooltip.switchToGrid": "Cambiar al diseño de cuadrícula", + "mutedAlert.message.forceMuted": "Has sido silenciado por otro participante. Haz clic en el micrófono para activarlo.", + "mutedAlert.message.muted": "¿Estás hablando? Tu micrófono está apagado. Haz clic en el micrófono para encenderlo.", + "participants.list.ariaLabel": "alternar lista de participantes", + "participants.list.close": "Cerrar lista de participantes", + "participants.list.open": "Abrir lista de participantes", + "participants.maxPin": "No puedes fijar más mosaicos", + "participants.mute.dialog.content": "¿Silenciar a {{participantName}} para todos en la llamada? Solo {{participantName}} puede activarse de nuevo.", + "participants.mute.tooltip": "Silenciar el micrófono de {{participantName}}", + "participants.pin": "Fijar a {{participantName}}", + "participants.pin.video": "Fijar el video de {{participantName}}", + "participants.screen": "Pantalla de {{participantName}}", + "participants.search": "Buscar participantes", + "participants.title": "Participantes", + "participants.unpin": "Desfijar a {{participantName}}", + "participants.unpin.video": "Desfijar el video de {{participantName}}", + "publishingErrors.accessDenied.message": "Parece que tu navegador tiene bloqueado el acceso a tu {{device}}. Restablece los permisos desde la interfaz del navegador.", + "publishingErrors.accessDenied.title": "Acceso a {{device}} denegado", + "publishingErrors.blocked.message": "Tenemos problemas para conectarte con otros en la sala. Por favor revisa tu red e intenta de nuevo.", + "publishingErrors.blocked.title": "Dificultades para unirse a la sala", + "subscribingErrors.blocked.message": "Tenemos problemas para conectarte con otros en la sala. Por favor revisa tu red e intenta de nuevo.", + "subscribingErrors.blocked.title": "Dificultades para unirse a la sala", + "recording.start.dialog.content": "¡Asegúrate de que todos estén listos! Puedes descargar la grabación desde la página de \"Despedida\" después de salir de la sala.", + "recording.start.dialog.title": "¿Iniciar grabación?", + "recording.start.title": "Iniciar grabación", + "recording.stop.dialog.content": "Puedes descargar la grabación desde la página de \"Despedida\" después de salir de la sala.", + "recording.stop.dialog.title": "¿Detener grabación?", + "recording.stop.title": "Detener grabación", + "recording.consent.dialog.title": "Esta reunión se está grabando", + "recording.consent.dialog.content": "El anfitrión ha iniciado la grabación de esta reunión. ¿Consientes ser grabado?", + "recording.consent.dialog.accept": "Consiento", + "recording.consent.dialog.decline": "No consiento", + "recording.consent.goodbye.header": "Grabación rechazada", + "recording.consent.goodbye.message": "Has decidido no consentir la grabación. Vuelve a unirte cuando la grabación se haya detenido o pide al anfitrión que desactive la grabación.", + "recording.popup.title": "Grabación", + "recording.popup.subtitle": "Esta reunión se está grabando", + "recording.tooltip.ariaLabel": "diseño de video", + "room.exit.ariaLabel": "salir", + "room.exit.tooltip": "Salir de la reunión", + "room.input.helper": "No se permite dejar el campo vacío, espacios ni caracteres especiales", + "room.input.label": "Nombre de sala", + "screenSharing.dialog.action": "Comenzar a compartir tu pantalla", + "screenSharing.dialog.content": "Parece que alguien más está compartiendo su pantalla. Si continúas, su pantalla dejará de compartirse.", + "screenSharing.dialog.title": "¿Deseas compartir tu pantalla?", + "screenSharing.dialog.hiddenMessage": "Estás compartiendo tu pantalla.", + "screenSharing.title.start": "Iniciar compartir pantalla", + "screenSharing.title.stop": "Detener compartir pantalla", + "screenSharing.tooltip.ariaLabel": "Iniciar o detener compartir pantalla", + "soundTest.start": "Probar altavoces", + "soundTest.stop": "Detener prueba", + "toolbar.tooltip.ariaLabel": "abrir menú de barra de herramientas adicional", + "toolbar.tooltip.title": "Acceder a elementos adicionales de la barra", + "unsupportedBrowser.header": "Tu navegador no es compatible", + "unsupportedBrowser.message": "Revisa la lista y elige un navegador compatible.", + "unsupportedBrowser.supported.visitPage": "Visitar la página", + "unsupportedBrowser.supported.title": "Navegadores compatibles:", + "unknown.device": "Dispositivo desconocido", + "user.unknown": "usuario desconocido", + "user.you": "Tú", + "waitingRoom.unsupportedFeature.tooltip": "Tu navegador no soporta esta funcionalidad", + "waitingRoom.devices.noDevicesFound": "No se han encontrado dispositivos", + "waitingRoom.title": "Prepararse para unirse", + "waitingRoom.user.input.title": "Ingresa tu nombre", + "waitingRoom.user.input.label": "Nombre", + "waitingRoom.precallNetworkTest.title": "Prueba de red previa a la llamada", + "waitingRoom.precallNetworkTest.subtitle": "Prueba tu acceso a dispositivos, conectividad de red y calidad de la llamada antes de unirte", + "waitingRoom.precallNetworkTest.stopTest": "Detener prueba", + "waitingRoom.precallNetworkTest.stoppedTitle": "Prueba detenida", + "waitingRoom.precallNetworkTest.stoppedMessage": "Has detenido la prueba de red. Puedes volver a intentarlo cuando estés listo.", + "waitingRoom.precallNetworkTest.retryTest": "Reintentar prueba", + "waitingRoom.precallNetworkTest.audio": "Audio", + "waitingRoom.precallNetworkTest.video": "Video", + "waitingRoom.precallNetworkTest.qualityLabel": "Calidad:", + "waitingRoom.precallNetworkTest.error": "La prueba falló", + "waitingRoom.precallNetworkTest.audioSupported": "El audio es compatible", + "waitingRoom.precallNetworkTest.audioNotSupported": "El audio no es compatible", + "waitingRoom.precallNetworkTest.videoSupported": "El video es compatible", + "waitingRoom.precallNetworkTest.videoNotSupported": "El video no es compatible", + "waitingRoom.user.input.error": "El nombre no puede estar vacío ni contener caracteres especiales.", + "zoom.reset": "Restablecer zoom", + "zoom.start": "Iniciar zoom", + "zoom.out": "Alejar zoom", + "zoom.in": "Acercar zoom", + "zoom.current": "Zoom actual", + "errorPage.title": "Algo salió mal", + "errorPage.description": "La aplicación no pudo iniciarse. Recarga la página o contacta con soporte si el problema persiste.", + "notification.dismiss": "Descartar notificación" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/index.ts new file mode 100644 index 00000000..488beffe --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/index.ts @@ -0,0 +1,35 @@ +import EN from './en.json'; +import DE from './de.json'; +import ES from './es.json'; +import IT from './it.json'; +import ES_MX from './es-MX.json'; + +export default { + en: { + translation: EN, + }, + 'en-US': { + translation: EN, + }, + de: { + translation: DE, + }, + 'de-DE': { + translation: DE, + }, + 'de-AT': { + translation: DE, + }, + 'de-CH': { + translation: DE, + }, + es: { + translation: ES, + }, + 'es-MX': { + translation: ES_MX, + }, + it: { + translation: IT, + }, +}; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/it.json b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/it.json new file mode 100644 index 00000000..933ac8a5 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/locales/it.json @@ -0,0 +1,318 @@ +{ + "archiveList.archive.createdAt": "Creata: {{createdAt}}", + "archiveList.archive.index": "Registrazione {{index}}", + "archiveList.download.tooltip": "Scarica registrazione {{id}}", + "archiveList.download": "Scarica", + "archiveList.empty": "La riunione non è stata registrata", + "archiveList.error.text": "Si è verificato un errore durante il caricamento delle registrazioni di questa riunione", + "archiveList.error.tooltip": "Questa registrazione è fallita o è scaduta", + "archiveList.label": "Scarica registrazioni", + "archiveList.loading": "Stiamo elaborando la tua registrazione", + "archiveList.loading.subtitle": "Attendere alcuni secondi per scaricare la registrazione", + "advancedSettings.title": "Impostazioni", + "advancedSettings.open": "Apri impostazioni", + "advancedSettings.close": "Chiudi impostazioni", + "advancedSettings.ariaLabel": "apri impostazioni", + "advancedSettings.tabs.general": "Generale", + "advancedSettings.tabs.video": "Video", + "advancedSettings.tabs.audio": "Audio", + "advancedSettings.tabs.statistics": "Statistiche", + "advancedSettings.general.resetButton": "Ripristina valori predefiniti", + "advancedSettings.general.resetDescription": "Ripristina tutte le impostazioni ai valori predefiniti", + "advancedSettings.video.bitrate.label": "Bitrate", + "advancedSettings.video.bitrate.options.default": "Predefinita", + "advancedSettings.video.bitrate.options.bw_saver": "Risparmio di banda", + "advancedSettings.video.bitrate.options.extra_bw_saver": "Risparmio extra di banda", + "advancedSettings.video.bitrate.options.custom": "Personalizzato", + "advancedSettings.video.bitrate.description": "Vonage Video API consente di usare preset bitrate o definire un bitrate video massimo personalizzato.", + "advancedSettings.video.customBitrate.label": "Bitrate personalizzato", + "advancedSettings.video.customBitrate.description": "Imposta un bitrate video massimo tra 5 kbps e 10 Mbps.", + "advancedSettings.video.customBitrate.minimum": "5 kbps", + "advancedSettings.video.customBitrate.maximum": "10 Mbps", + "advancedSettings.video.customBitrate.units.lower": "kbps", + "advancedSettings.video.customBitrate.units.higher": "Mbps", + "advancedSettings.video.codec.label": "Codec", + "advancedSettings.video.codec.options.automatic": "Automatico", + "advancedSettings.video.codec.options.manual": "Manuale", + "advancedSettings.video.codec.description": "L'SDK seleziona automaticamente il codec migliore in base alle condizioni di rete e alle capacità dei partecipanti. Le modifiche si applicano alla chiamata successiva.", + "advancedSettings.video.codec.priority.label": "Priorità codec", + "advancedSettings.video.codec.priority.description": "Trascina per scegliere l'ordine di preferenza dei codec, dal primo all'ultimo.", + "advancedSettings.video.codec.priority.options.vp9": "VP9", + "advancedSettings.video.codec.priority.options.vp8": "VP8", + "advancedSettings.video.codec.priority.options.h264": "H.264", + "advancedSettings.video.frameRate.label": "Frame rate", + "advancedSettings.video.frameRate.options.30": "30 FPS", + "advancedSettings.video.frameRate.options.15": "15 FPS", + "advancedSettings.video.frameRate.options.7": "7 FPS", + "advancedSettings.video.frameRate.options.1": "1 FPS", + "advancedSettings.video.resolution.label": "Risoluzione", + "advancedSettings.video.error.resolutionNotSupported": "La risoluzione selezionata non è supportata dal tuo dispositivo.", + "advancedSettings.video.error.frameRateNotSupported": "Il frame rate selezionato non è supportato dal tuo dispositivo.", + "advancedSettings.video.error.bitrateNotSupported": "Non è stato possibile applicare il bitrate selezionato.", + "advancedSettings.audio.bitrate.label": "Bitrate audio", + "advancedSettings.audio.bitrate.description": "Utilizza automaticamente il valore predefinito dell'SDK, oppure scegli personalizzato per definire il bitrate massimo di codifica audio inviato alla sessione.", + "advancedSettings.audio.bitrate.options.automatic": "Automatico", + "advancedSettings.audio.bitrate.options.custom": "Personalizzato", + "advancedSettings.audio.bitrate.customLabel": "Bitrate audio personalizzato", + "advancedSettings.audio.bitrate.minimum": "6 kbps", + "advancedSettings.audio.bitrate.maximum": "510 kbps", + "advancedSettings.audio.bitrate.currentValue": "{{value}} kbps", + "advancedSettings.audio.enableDtx.label": "Abilita Opus DTX", + "advancedSettings.audio.enableDtx.description": "Quando attivato, la trasmissione discontinua di Opus riduce la larghezza di banda audio durante i periodi di silenzio.", + "advancedSettings.audio.publisherAudioFallback.label": "Fallback audio del publisher", + "advancedSettings.audio.publisherAudioFallback.description": "Quando è abilitato, il tuo video smette di essere renderizzato su altri dispositivi durante condizioni di rete scarse per preservare l'audio.", + "advancedSettings.audio.subscriberAudioFallback.label": "Fallback audio del subscriber", + "advancedSettings.audio.subscriberAudioFallback.description": "Quando è abilitato, ricevi solo l'audio degli altri partecipanti durante condizioni di rete scarse.", + "advancedSettings.audio.nextCallWarningWaitingRoom": "Le modifiche avranno effetto quando entri nella chiamata.", + "advancedSettings.audio.nextCallWarningMeetingRoom": "Le modifiche avranno effetto nella prossima chiamata o dopo aver aggiornato la pagina.", + "advancedSettings.statistics.collection.enablePublisher.label": "Abilita stima della larghezza di banda del publisher", + "advancedSettings.statistics.collection.enablePublisher.description": "Quando è abilitato, mostra la larghezza di banda in uscita stimata per il publisher locale.", + "advancedSettings.statistics.groups.publisher": "Publisher", + "advancedSettings.statistics.groups.subscribers": "Subscribers", + "advancedSettings.statistics.empty": "Non ci sono ancora statistiche disponibili.", + "advancedSettings.statistics.sections.audio": "Audio", + "advancedSettings.statistics.sections.video": "Video", + "advancedSettings.statistics.metrics.packetsSent": "Pacchetti inviati", + "advancedSettings.statistics.metrics.packetsLostSent": "Pacchetti persi (invio)", + "advancedSettings.statistics.metrics.bytesSent": "Byte inviati", + "advancedSettings.statistics.metrics.packetsReceived": "Pacchetti ricevuti", + "advancedSettings.statistics.metrics.packetsLostReceived": "Pacchetti persi (ricezione)", + "advancedSettings.statistics.metrics.bytesReceived": "Byte ricevuti", + "advancedSettings.statistics.metrics.estimatedBandwidth": "Larghezza di banda stimata", + "advancedSettings.statistics.metrics.remotePublisherEstimatedBandwidth": "Larghezza di banda stimata del publisher remoto", + "advancedSettings.statistics.metrics.resolution": "Risoluzione", + "advancedSettings.statistics.metrics.frameRate": "Frequenza fotogrammi", + "advancedSettings.statistics.metrics.decodedFrameRate": "Frequenza fotogrammi decodificata", + "advancedSettings.statistics.metrics.bitrate": "Bitrate", + "advancedSettings.statistics.metrics.packetLoss": "Perdita di pacchetti", + "advancedSettings.statistics.metrics.freezeCount": "Numero di blocchi", + "advancedSettings.statistics.metrics.totalFreezesDuration": "Durata totale dei blocchi", + "advancedSettings.statistics.metrics.codec": "Codec", + "advancedSettings.statistics.metrics.videoLayer": "Livello {{index}} ({{codec}})", + "backgroundEffects.limit": "Hai raggiunto il limite massimo di immagini personalizzate", + "backgroundEffects.title": "Effetti video", + "backgroundEffects.addBackground": "Aggiungi sfondo", + "backgroundEffects.video.disabled": "La tua camera è spenta.", + "backgroundEffects.invalidFileType": "Sono consentite solo immagini JPG, PNG, GIF o BMP.", + "backgroundEffects.fileTooLarge": "L'immagine deve essere inferiore a {{maxSize}}MB.", + "backgroundEffects.processingError": "Errore nell'elaborazione dell'immagine caricata.", + "backgroundEffects.storageError": "Errore nel salvataggio dell'immagine.", + "backgroundEffects.maxSize": "Max {{maxSize}}MB", + "backgroundEffects.removeBackground": "Rimuovi sfondo", + "backgroundEffects.slightBlur": "Sfocatura leggera dello sfondo", + "backgroundEffects.strongBlur": "Sfocatura forte dello sfondo", + "backgroundEffects.yourBackground": "Il tuo sfondo", + "backgroundEffects.deleteTooltipInUse": "Non puoi rimuovere questo sfondo mentre è in uso", + "backgroundEffects.deleteTooltip": "Rimuovi sfondo", + "backgroundEffects.deleteAriaLabel": "Rimuovi sfondo personalizzato", + "backgroundEffects.backgrounds.bookshelfRoom": "Stanza con libreria", + "backgroundEffects.backgrounds.busyRoom": "Stanza occupata", + "backgroundEffects.backgrounds.duneView": "Vista delle dune", + "backgroundEffects.backgrounds.hogwarts": "Hogwarts", + "backgroundEffects.backgrounds.library": "Biblioteca", + "backgroundEffects.backgrounds.newYork": "New York", + "backgroundEffects.backgrounds.plane": "Aereo", + "backgroundEffects.backgrounds.whiteRoom": "Stanza bianca", + "button.apply": "Applica", + "button.camera": "Camera", + "button.cancel": "Annulla", + "button.close": "Chiudi", + "button.createRoom": "Crea una nuova stanza", + "button.startNewRoom": "Inizia una nuova videoconferenza", + "button.joinExistingMeeting": "Unisciti a una riunione esistente", + "button.joinWaitingRoom": "Unisciti alla sala d'attesa", + "button.join": "Partecipa alla riunione", + "button.mute": "Disattiva audio", + "button.send": "Invia", + "button.speaker": "Altoparlante", + "captions.ariaLabel": "pulsante sottotitoli", + "captions.disable": "Disattiva sottotitoli", + "captions.enable": "Attiva sottotitoli", + "captions.errors": "Errore sottotitoli: {{captionsErrorResponse}}", + "chat.ariaLabel": "attiva/disattiva chat", + "chat.close": "Chiudi chat", + "chat.copied": "Copiato", + "chat.copy": "Copia negli appunti", + "chat.input.placeholder": "Invia un messaggio", + "chat.meetingUrl": "URL riunione:", + "chat.open": "Apri chat", + "chat.title": "Chat", + "common.or": "o", + "connectionAlert.quality.message": "Controlla la tua connessione. Il video potrebbe essere disattivato per migliorare l'esperienza.", + "connectionAlert.quality.title": "Problema relativo alla qualità del video", + "connectionAlert.reconnecting.message": "Verifica la tua connessione di rete", + "connectionAlert.reconnecting.title": "Connessione persa", + "deviceAccessAlert.askDeviceMessage": "Per partecipare alla Riunione, il browser richiederà l'accesso a camera e microfono.", + "deviceAccessAlert.deniedDeviceMessage": "Sembra che il browser non possa accedere alla camera o al microfono. Reimposta i permessi tramite l'interfaccia del browser.", + "deviceAccessAlert.imageAlt": "Dialogo accesso", + "devices.audio.ariaLabel": "dispositivi audio", + "devices.audio.defaultLabel": "Predefinito sistema", + "devices.audio.disable": "Disattiva microfono", + "devices.audio.enable": "Attiva il microfono o tieni premuta la barra spaziatrice per attivarlo temporaneamente", + "devices.audio.microphone.ariaLabel": "attiva/disattiva audio", + "devices.audio.microphone.full": "Microfono", + "devices.audio.microphone.short": "Mic", + "devices.audio.microphone.state.on": "Attiva microfono", + "devices.audio.microphone.state.off": "Disattiva microfono", + "devices.audio.noiseSuppression": "Soppressione rumore avanzata", + "devices.audio.speakers.full": "Altoparlanti", + "devices.buttons.ariaLabel": "pulsante diviso", + "devices.settings.ariaLabel": "impostazioni dispositivi", + "devices.select.unknownDevice": "dispositivo", + "devices.select.errorContext": "Impossibile selezionare {{device}}. Controlla i permessi del dispositivo e riprova.", + "devices.select.accessDenied": "L'accesso a {{device}} è stato negato. Controlla i permessi del browser.", + "devices.select.trackUnavailable": "{{device}} non è attualmente disponibile. Controlla la connessione del dispositivo.", + "devices.video.ariaLabel": "dispositivi video", + "devices.video.blur.ariaLabel": "Attiva la sfocatura dello sfondo", + "devices.video.blur.label": "Sfoca lo sfondo", + "devices.video.camera.ariaLabel": "attiva/disattiva video", + "devices.video.camera.switch": "Cambia camera", + "devices.video.camera.state.on": "Attiva camera", + "devices.video.camera.state.off": "Disattiva camera", + "devices.video.camera.full": "Camera", + "devices.video.camera.front": "frontale", + "devices.video.camera.rear": "posteriore", + "devices.video.camera.switchError": "Impossibile passare alla fotocamera {{facingMode}}", + "devices.video.camera.accessError": "Impossibile utilizzare la fotocamera {{facingMode}}. Controlla i permessi della fotocamera e riprova.", + "devices.video.camera.noTrackError": "La fotocamera {{facingMode}} non si è avviata correttamente. Prova un'altra fotocamera.", + "devices.video.camera.noDeviceError": "Nessuna fotocamera {{facingMode}} trovata", + "devices.video.camera.noCurrentDeviceError": "Nessuna fotocamera attualmente selezionata. Seleziona una fotocamera per primo.", + "devices.video.camera.selectedDevice": "Fotocamera selezionata: {{deviceName}}", + "devices.video.disable": "Disattiva video", + "devices.video.enable": "Attiva video", + "emoji.ariaLabel": "apri menu emoji", + "emoji.tooltip": "Esprimiti", + "errors.unknown": "Errore sconosciuto", + "feedbackForm.ariaLabel": "attiva modulo segnalazione", + "feedbackForm.close": "Chiudi modulo segnalazione", + "feedbackForm.disclaiamer.label": "Non includere informazioni sensibili.", + "feedbackForm.disclaiamer.screenshot": "Uno screenshot ci aiuterà a capire meglio il problema. (opzionale)", + "feedbackForm.field.issue.label": "Descrivi il problema", + "feedbackForm.field.issue.type": "Descrizione", + "feedbackForm.field.name.label": "Indicaci il tuo nome", + "feedbackForm.field.name.type": "Il tuo nome", + "feedbackForm.field.title.label": "Quando hai notato questo problema, cosa stavi facendo?", + "feedbackForm.field.title.type": "Titolo", + "feedbackForm.helperText.error": "{{field}} è richiesto e deve essere inferiore a {{limit}} caratteri", + "feedbackForm.open": "Apri modulo segnalazione", + "feedbackForm.submitted.content": "Grazie per il tuo feedback, ci aiuta a migliorare l'app.", + "feedbackForm.submitted.error": "Si è verificato un errore. Riprova.", + "feedbackForm.submitted.thanks": "Grazie per il tuo feedback!", + "feedbackForm.submitted.track": "Segui i progressi qui.", + "feedbackForm.title": "Segnala problema", + "filePicker.addScreenshot": "Aggiungi screenshot", + "filePicker.attachedScreenshot": "Screenshot allegato", + "filePicker.capture": "Cattura screenshot", + "filePicker.delete": "Elimina screenshot", + "filePicker.sizeLimit": "La dimensione massima del file è 20MB. Carica un altro file.", + "footer.github.title": "Applicazione di riferimento video di Vonage", + "imageStorage.imagesExceedSize": "Le immagini sono troppo grandi per essere archiviate (~4MB max).", + "imageStorage.storageFailure": "Impossibile salvare le immagini nel localStorage.", + "imageStorage.duplicateImage": "Questa immagine è già stata aggiunta.", + "imageStorage.fileReadError": "Impossibile leggere il file immagine.", + "imageStorage.invalidExtension": "Estensione immagine non valida.", + "imageStorage.invalidUrl": "URL immagine non valido.", + "imageStorage.convertError": "Impossibile convertire l'immagine.", + "imageStorage.loadError": "Impossibile caricare l'immagine.", + "githubTooltip": "Visita il nostro GitHub", + "goodBye.title": "Rientro nella stanza", + "goodBye.back": "Visualizza pagina di destinazione", + "goodBye.reEnter": "Torna alla riunione", + "goodbye.default.header": "Hai lasciato la riunione", + "goodbye.default.message": "Grazie per aver partecipato!", + "landing.welcome.subtitle": "Potenzia la tua attività con video che trasformano la soddisfazione del cliente.", + "landing.welcome.title.1": "Migliora", + "landing.welcome.title.2": "la comunicazione", + "landing.welcome.title.3": "per video", + "landing.primary.word": "video", + "layout.tooltip.isPinningPresent": "Impossibile cambiare layout mentre un partecipante è fissato", + "layout.tooltip.isScreenSharePresent": "Impossibile cambiare il layout mentre è attiva la condivisione dello schermo", + "layout.tooltip.switchToActiveSpeaker": "Passa al layout Oratore in evidenza", + "layout.tooltip.switchToGrid": "Passa al layout Griglia", + "mutedAlert.message.forceMuted": "Sei stato disattivato da un altro partecipante. Clicca sul microfono per riattivarlo.", + "mutedAlert.message.muted": "Stai parlando? Il tuo microfono è attualmente disattivato. Clicca sul microfono per attivarlo.", + "participants.list.ariaLabel": "attiva/disattiva la lista dei partecipanti", + "participants.list.close": "Chiudi la lista dei partecipanti", + "participants.list.open": "Apri la lista dei partecipanti", + "participants.maxPin": "Non puoi fissare altre tile", + "participants.mute.dialog.content": "Disattivare {{participantName}} per tutti nella chiamata? Solo {{participantName}} può riattivarsi.", + "participants.mute.tooltip": "Disattiva microfono di {{participantName}}", + "participants.pin": "Fissa {{participantName}}", + "participants.pin.video": "Fissa video di {{participantName}}", + "participants.screen": "Schermo di {{participantName}}", + "participants.search": "Cerca partecipanti", + "participants.title": "Partecipanti", + "participants.unpin": "Sblocca {{participantName}}", + "participants.unpin.video": "Sblocca video di {{participantName}}", + "publishingErrors.accessDenied.message": "Sembra che il browser non possa accedere al tuo {{device}}. Reimposta i permessi tramite il browser.", + "publishingErrors.accessDenied.title": "L'accesso a {{device}} è stato negato", + "publishingErrors.blocked.message": "Problemi di connessione con altri nella stanza. Controlla la rete e riprova.", + "publishingErrors.blocked.title": "Difficoltà a unirsi alla stanza", + "subscribingErrors.blocked.message": "Problemi di connessione con altri nella stanza. Controlla la rete e riprova.", + "subscribingErrors.blocked.title": "Difficoltà a unirsi alla stanza", + "recording.start.dialog.content": "Assicurati che tutti siano pronti! Puoi scaricare la registrazione dalla pagina \"Addio\" dopo aver lasciato la stanza.", + "recording.start.dialog.title": "Avvia registrazione?", + "recording.start.title": "Avvia registrazione", + "recording.stop.dialog.content": "Puoi scaricare la registrazione dalla pagina \"Addio\" dopo aver lasciato la stanza.", + "recording.stop.dialog.title": "Interrompere la registrazione?", + "recording.stop.title": "Interrompi registrazione", + "recording.consent.dialog.title": "Questa riunione è in registrazione", + "recording.consent.dialog.content": "L'organizzatore ha avviato la registrazione di questa riunione. Acconsenti a essere registrato?", + "recording.consent.dialog.accept": "Acconsento", + "recording.consent.dialog.decline": "Non acconsento", + "recording.consent.goodbye.header": "Registrazione rifiutata", + "recording.consent.goodbye.message": "Hai scelto di non acconsentire alla registrazione. Rientra quando la registrazione è stata interrotta o chiedi all'organizzatore di disattivarla.", + "recording.popup.title": "Registrazione", + "recording.popup.subtitle": "Questa riunione viene registrata", + "recording.tooltip.ariaLabel": "layout video", + "room.exit.ariaLabel": "esci", + "room.exit.tooltip": "Esci dalla riunione", + "room.input.helper": "Non può essere vuoto, contenere spazi o caratteri speciali", + "room.input.label": "Nome della stanza", + "screenSharing.dialog.action": "Inizia condivisione schermo", + "screenSharing.dialog.content": "Sembra che qualcun altro stia condividendo lo schermo. Se continui, il loro schermo non sarà più condiviso.", + "screenSharing.dialog.title": "Vuoi condividere il tuo schermo?", + "screenSharing.dialog.hiddenMessage": "Stai condividendo il tuo schermo.", + "screenSharing.title.start": "Avvia condivisione schermo", + "screenSharing.title.stop": "Interrompi condivisione schermo", + "screenSharing.tooltip.ariaLabel": "Avvia o interrompi condivisione schermo", + "soundTest.start": "Test altoparlanti", + "soundTest.stop": "Interrompi test", + "toolbar.tooltip.ariaLabel": "apri menu strumenti aggiuntivi", + "toolbar.tooltip.title": "Accedi agli strumenti aggiuntivi", + "unsupportedBrowser.header": "Browser non supportato", + "unsupportedBrowser.message": "Controlla l'elenco e scegli un browser supportato.", + "unsupportedBrowser.supported.visitPage": "Visita la pagina", + "unsupportedBrowser.supported.title": "Browser supportati:", + "unknown.device": "Dispositivo sconosciuto", + "user.unknown": "utente sconosciuto", + "user.you": "Tu", + "waitingRoom.unsupportedFeature.tooltip": "Il tuo browser non supporta questa funzionalità", + "waitingRoom.devices.noDevicesFound": "Nessun dispositivo trovato", + "waitingRoom.title": "Preparati per unirti", + "waitingRoom.user.input.title": "Inserisci il tuo nome", + "waitingRoom.user.input.label": "Nome", + "waitingRoom.precallNetworkTest.title": "Test di rete pre-chiamata", + "waitingRoom.precallNetworkTest.subtitle": "Verifica l'accesso ai dispositivi, la connettività di rete e la qualità della chiamata prima di unirti", + "waitingRoom.precallNetworkTest.stopTest": "Interrompi test", + "waitingRoom.precallNetworkTest.stoppedTitle": "Test interrotto", + "waitingRoom.precallNetworkTest.stoppedMessage": "Hai interrotto il test di rete. Puoi riprovare quando vuoi.", + "waitingRoom.precallNetworkTest.retryTest": "Riprova test", + "waitingRoom.precallNetworkTest.audio": "Audio", + "waitingRoom.precallNetworkTest.video": "Video", + "waitingRoom.precallNetworkTest.qualityLabel": "Qualità:", + "waitingRoom.precallNetworkTest.error": "Test fallito", + "waitingRoom.precallNetworkTest.audioSupported": "L'audio è supportato", + "waitingRoom.precallNetworkTest.audioNotSupported": "L'audio non è supportato", + "waitingRoom.precallNetworkTest.videoSupported": "Il video è supportato", + "waitingRoom.precallNetworkTest.videoNotSupported": "Il video non è supportato", + "waitingRoom.user.input.error": "Il nome non può essere vuoto né contenere caratteri speciali.", + "zoom.reset": "Reimposta zoom", + "zoom.start": "Inizia zoom", + "zoom.out": "Zoom indietro", + "zoom.in": "Zoom avanti", + "zoom.current": "Zoom attuale", + "errorPage.title": "Qualcosa è andato storto", + "errorPage.description": "L'applicazione non è riuscita ad avviarsi. Ricarica la pagina o contatta il supporto se il problema persiste.", + "notification.dismiss": "Ignora notifica" +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.spec.ts new file mode 100644 index 00000000..9228413b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.spec.ts @@ -0,0 +1,109 @@ +import { waitFor } from '@testing-library/dom'; +import { vi, describe, it, beforeEach, expect } from 'vitest'; +import { FrontendLogger } from './FrontendLogger'; +import type { LoggerProviderConfig } from '@common/logger'; + +describe('FrontendLogger', () => { + let provider: LoggerProviderConfig; + let logger: FrontendLogger; + + beforeEach(() => { + provider = { log: vi.fn(), reportError: vi.fn(), verbose: false }; + logger = new FrontendLogger(); + logger.setup(() => provider); + vi.clearAllMocks(); + }); + + it('forwards log and reportError calls to provider', async () => { + logger.log('EventName', { key: 'value' }); + logger.reportError(new Error('Err'), { context: 'test' }); + + await waitFor(() => { + expect(provider.log).toHaveBeenCalledWith( + 'EventName', + expect.objectContaining({ key: 'value' }) + ); + }); + await waitFor(() => { + expect(provider.reportError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Err' }), + { context: 'test' } + ); + }); + }); + + it('handles synchronous setup correctly', async () => { + const syncProvider: LoggerProviderConfig = { log: vi.fn(), reportError: vi.fn() }; + const syncLogger = new FrontendLogger(); + syncLogger.setup(() => syncProvider); + + syncLogger.log('SyncEvent', { id: '1' }); + syncLogger.reportError(new Error('Sync error'), { source: 'test' }); + + await waitFor(() => { + expect(syncProvider.log).toHaveBeenCalledWith( + 'SyncEvent', + expect.objectContaining({ id: '1' }) + ); + }); + await waitFor(() => { + expect(syncProvider.reportError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Sync error' }), + { source: 'test' } + ); + }); + }); + + it('reports initialization failure when setup promise rejects', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const badLogger = new FrontendLogger(); + + badLogger.setup(() => Promise.reject(new Error('Failed to load provider'))); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Initialization failed'), + expect.any(Error) + ); + }); + }); + + it('logs to console when provider is verbose', async () => { + provider.verbose = true; + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + logger.log('VerboseEvent', { test: 1 }); + logger.reportError(new Error('VerboseError'), { type: 'test' }); + + await waitFor(() => { + expect(consoleLogSpy).toHaveBeenCalled(); + }); + }); + + it.each([ + ['onCaughtError', 'caught', { componentStack: 'stack1' }, { componentStack: 'stack1' }], + ['onUncaughtError', 'uncaught', { componentStack: 'stack2' }, { componentStack: 'stack2' }], + ['onUncaughtError', 'uncaught', undefined, { componentStack: undefined }], + [ + 'onRecoverableError', + 'recoverable', + { componentStack: 'stack3' }, + { componentStack: 'stack3' }, + ], + ['onRecoverableError', 'recoverable', {}, { componentStack: undefined }], + ])('%s calls reportError with type "%s"', async (_method, type, errorInfoArg, expectedExtra) => { + const err = new Error('Test error'); + const loggerWithCallbacks = logger as unknown as Record< + string, + (e: unknown, info: unknown) => void + >; + loggerWithCallbacks[_method](err, errorInfoArg); + + await waitFor(() => { + expect(provider.reportError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Test error' }), + { type, ...expectedExtra } + ); + }); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.ts new file mode 100644 index 00000000..9633c1fc --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/FrontendLogger.ts @@ -0,0 +1,35 @@ +import { LoggerBase, type LoggerProviderConfig } from '@common/logger'; +import type { ErrorInfo } from 'react-dom/client'; + +export type FrontendLoggerProviderConfig = LoggerProviderConfig & { + onCaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void; + onUncaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void; + onRecoverableError?: (error: Error, errorInfo: ErrorInfo) => void; +}; + +/** + * FrontendLogger extends LoggerBase with React error callbacks (onCaughtError, onUncaughtError, onRecoverableError). + * Logs are sent via the configured provider (e.g. backend → Gollum); not to the console. + */ +export class FrontendLogger extends LoggerBase { + public onCaughtError = (error: unknown, errorInfo: { componentStack?: string }) => { + this.reportError(error, { + type: 'caught', + componentStack: errorInfo.componentStack, + }); + }; + + public onUncaughtError = (error: unknown, errorInfo: { componentStack?: string }) => { + this.reportError(error, { + type: 'uncaught', + componentStack: errorInfo?.componentStack, + }); + }; + + public onRecoverableError = (error: unknown, errorInfo: ErrorInfo) => { + this.reportError(error, { + type: 'recoverable', + componentStack: errorInfo?.componentStack, + }); + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.spec.ts new file mode 100644 index 00000000..58b7a244 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.spec.ts @@ -0,0 +1,121 @@ +import { vi, describe, it, expect } from 'vitest'; +import { createClientEvent } from './createClientEvent'; + +const { MOCK_VERSION } = vi.hoisted(() => ({ + MOCK_VERSION: 'vera-1.0.0-test', +})); + +vi.mock('@utils/getAppVersion', () => ({ + default: () => MOCK_VERSION, +})); + +describe('createClientEvent', () => { + it('returns a ClientLogEvent with required fields', () => { + const event = createClientEvent({ + level: 'info', + action: 'TestAction', + payload: { key: 'value' }, + sessionId: 'sid-1', + connectionId: 'conn-1', + timestamp: 1234567890, + partnerId: 'partner-123', + }); + + expect(event).toMatchObject({ + action: 'TestAction', + payload: { key: 'value' }, + sessionId: 'sid-1', + connectionId: 'conn-1', + clientSystemTime: 1234567890, + level: 'info', + }); + expect(event.guid).toBeDefined(); + expect(typeof event.guid).toBe('string'); + expect(event.userAgent).toBeDefined(); + expect(typeof event.userAgent).toBe('string'); + }); + + it('uses Date.now() when clientSystemTime is not provided', () => { + const before = Date.now(); + const event = createClientEvent({ + level: 'info', + action: 'A', + sessionId: 'sid-1', + connectionId: 'conn-1', + partnerId: 'partner-123', + }); + const after = Date.now(); + + expect(event.clientSystemTime).toBeGreaterThanOrEqual(before); + expect(event.clientSystemTime).toBeLessThanOrEqual(after); + }); + + it('sets clientVersion, source, name, componentId from app/env and partnerId from input', () => { + const event = createClientEvent({ + level: 'error', + action: 'Error', + payload: {}, + partnerId: 'partner-123', + sessionId: 'sid-1', + connectionId: 'conn-1', + }); + + expect(event.clientVersion).toBe(MOCK_VERSION); + expect(event.source).toBeDefined(); + expect(event.name).toBe('vera'); + expect(event.componentId).toBe('vera'); + expect(event.partnerId).toBe('partner-123'); + }); + + it('omits sessionId, connectionId, partnerId when not provided', () => { + const event = createClientEvent({ + level: 'info', + action: 'TestAction', + payload: { key: 'value' }, + }); + + expect(event).not.toHaveProperty('sessionId'); + expect(event).not.toHaveProperty('connectionId'); + expect(event).not.toHaveProperty('partnerId'); + expect(event).toMatchObject({ action: 'TestAction', payload: { key: 'value' } }); + }); + + it('includes userId in the event when provided', () => { + const event = createClientEvent({ + level: 'info', + action: 'TestAction', + userId: 'user-123', + }); + + expect(event).toHaveProperty('userId', 'user-123'); + }); + + it('omits userId when not provided', () => { + const event = createClientEvent({ + level: 'info', + action: 'TestAction', + }); + + expect(event).not.toHaveProperty('userId'); + }); + + it('uses the same guid for all events from the same module (same page load)', () => { + const event1 = createClientEvent({ + level: 'info', + action: 'Error', + sessionId: 'sid-1', + connectionId: 'conn-1', + partnerId: 'partner-123', + }); + const event2 = createClientEvent({ + level: 'info', + action: 'Error', + sessionId: 'sid-1', + connectionId: 'conn-1', + timestamp: 1234567890, + partnerId: 'partner-123', + }); + + expect(event1.guid).toBe(event2.guid); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.ts new file mode 100644 index 00000000..29b2b04a --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/createClientEvent.ts @@ -0,0 +1,55 @@ +import type { ClientLogEvent } from '@common/types'; +import getAppVersion from '@utils/getAppVersion'; +import OT from '@vonage/client-sdk-video'; +import uniqueId from 'react-global-state-hooks/uniqueId'; + +/** Correlation id: same for all events from this page load. Enables grouping in Kibana. */ +const correlationIdForPageLoad = uniqueId('page-load:'); + +type ClientEventInput = { + level: ClientLogEvent['level']; + action: string; + payload?: Record; + sessionId?: string; + connectionId?: string; + partnerId?: string; + userId?: string; + variation?: string; + logVersion?: string; + timestamp?: number; + name?: string; + componentId?: string; +}; + +const getPlatform = () => ({ + userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent, + source: + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'unknown', +}); + +/** + * Builds a ClientLogEvent from input, filling in guid, userAgent, source, clientVersion, etc. + * Uses input.timestamp or Date.now() for clientSystemTime. + */ +export function createClientEvent(input: ClientEventInput): ClientLogEvent { + const { userAgent, source } = getPlatform(); + return { + action: input.action, + variation: input.variation, + logVersion: input.logVersion ?? '2', + payload: input.payload, + clientSystemTime: input.timestamp ?? Date.now(), + level: input.level, + guid: correlationIdForPageLoad, + clientVersion: getAppVersion(), + sdkId: (OT as { version?: string }).version ?? 'unknown', + source, + userAgent, + name: input.name ?? 'vera', + componentId: input.componentId ?? 'vera', + ...(input.sessionId != null && { sessionId: input.sessionId }), + ...(input.connectionId != null && { connectionId: input.connectionId }), + ...(input.partnerId != null && { partnerId: input.partnerId }), + ...(input.userId != null && { userId: input.userId }), + }; +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/index.ts new file mode 100644 index 00000000..a1d0a711 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/event/index.ts @@ -0,0 +1 @@ +export { createClientEvent } from './createClientEvent'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/index.ts new file mode 100644 index 00000000..20d4e974 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/index.ts @@ -0,0 +1,3 @@ +import { FrontendLogger } from './FrontendLogger'; + +export default new FrontendLogger(); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.spec.ts new file mode 100644 index 00000000..065e018d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.spec.ts @@ -0,0 +1,103 @@ +import { vi, describe, it, beforeEach, expect, type MockInstance } from 'vitest'; +import { BackendLoggingProvider } from './backendLoggingProvider'; + +const { MOCK_API_URL, MOCK_VERSION } = vi.hoisted(() => ({ + MOCK_API_URL: 'https://api.test', + MOCK_VERSION: 'vera-1.0.0-test', +})); + +vi.mock('../../utils/constants', () => ({ + API_URL: MOCK_API_URL, +})); + +vi.mock('../../utils/getAppVersion', () => ({ + default: () => MOCK_VERSION, +})); + +describe('BackendLoggingProvider', () => { + let fetchSpy: MockInstance< + [input: string | URL | Request, init?: RequestInit], + Promise + >; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response); + Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true }); + }); + + it('log() sends POST with ClientLogEvent shape and does not throw', () => { + const provider = new BackendLoggingProvider(); + + expect(() => { + provider.log('vonageVideoClient.connect.success', { + sessionId: 's1', + connectionId: 'c1', + partnerId: 'apiKey', + custom: 'data', + }); + }).not.toThrow(); + + expect(fetchSpy).toHaveBeenCalledWith( + `${MOCK_API_URL}/client-logs`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + }) + ); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body).toMatchObject({ + action: 'vonageVideoClient.connect.success', + variation: 'Success', + level: 'info', + clientVersion: MOCK_VERSION, + name: 'vera', + componentId: 'vera', + sessionId: 's1', + connectionId: 'c1', + partnerId: 'apiKey', + }); + expect(body.payload).toMatchObject({ custom: 'data' }); + expect(body.guid).toBeDefined(); + expect(body.clientSystemTime).toBeDefined(); + expect(body.userAgent).toBeDefined(); + }); + + it('reportError() sends POST with error payload and variation from error.name', () => { + const provider = new BackendLoggingProvider(); + const err = new Error('Something broke'); + + expect(() => { + provider.reportError(err, { sessionId: 's2', context: 'test' }); + }).not.toThrow(); + + expect(fetchSpy).toHaveBeenCalledWith( + `${MOCK_API_URL}/client-logs`, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body).toMatchObject({ + action: 'Error', + variation: 'Error', + level: 'error', + clientVersion: MOCK_VERSION, + sessionId: 's2', + }); + expect(body.payload).toMatchObject({ context: 'test' }); + }); + + it('reportError() uses error.name as variation for non-Error objects with name', () => { + const provider = new BackendLoggingProvider(); + const custom = Object.assign(new Error('msg'), { name: 'CustomError' }); + + provider.reportError(custom, {}); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body.variation).toBe('CustomError'); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.ts new file mode 100644 index 00000000..1b76285c --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/backendLoggingProvider.ts @@ -0,0 +1,11 @@ +import type { LoggerProviderConfig } from '@common/logger'; +import { BackendLogTransport } from '../transport'; + +export class BackendLoggingProvider implements LoggerProviderConfig { + private readonly transport = new BackendLogTransport(); + + verbose = false; + log = (...args: Parameters) => this.transport.log(...args); + reportError = (...args: Parameters) => + this.transport.reportError(...args); +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/index.ts new file mode 100644 index 00000000..44ef1ef1 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/providers/index.ts @@ -0,0 +1 @@ +export { BackendLoggingProvider } from './backendLoggingProvider'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.spec.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.spec.ts new file mode 100644 index 00000000..ef0bc3b2 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.spec.ts @@ -0,0 +1,70 @@ +import { vi, describe, it, beforeEach, expect, type MockInstance } from 'vitest'; +import { BackendLogTransport } from './index'; + +const MOCK_API_URL = vi.hoisted(() => 'https://api.test'); + +vi.mock('../../utils/constants', () => ({ + API_URL: MOCK_API_URL, +})); + +vi.mock('../../utils/getAppVersion', () => ({ + default: () => 'vera-1.0.0-test', +})); + +describe('BackendLogTransport', () => { + let fetchSpy!: MockInstance< + [input: string | URL | Request, init?: RequestInit], + Promise + >; + let sendBeaconSpy!: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response); + sendBeaconSpy = vi.fn().mockReturnValue(true); + Object.defineProperty(navigator, 'sendBeacon', { value: sendBeaconSpy, configurable: true }); + }); + + it('log() uses sendBeacon when available', () => { + const transport = new BackendLogTransport(); + transport.log('Test', { sessionId: 's1', connectionId: 'c1', partnerId: 'p1' }); + + expect(sendBeaconSpy).toHaveBeenCalledWith(`${MOCK_API_URL}/client-logs`, expect.any(Blob)); + const blob = sendBeaconSpy.mock.calls[0][1] as Blob; + expect(blob.type).toBe('application/json'); + expect(blob.size).toBeGreaterThan(0); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('log() falls back to fetch when sendBeacon unavailable', () => { + Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true }); + const transport = new BackendLogTransport(); + transport.log('Test', { sessionId: 's1', connectionId: 'c1', partnerId: 'p1' }); + + expect(fetchSpy).toHaveBeenCalledWith( + `${MOCK_API_URL}/client-logs`, + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }); + + it('log() does not throw when sendBeacon returns false', () => { + sendBeaconSpy.mockReturnValue(false); + const transport = new BackendLogTransport(); + + expect(() => transport.log('Test', {})).not.toThrow(); + }); + + it('reportError() uses sendBeacon when available', () => { + const transport = new BackendLogTransport(); + transport.reportError(new Error('Test error'), { sessionId: 's1' }); + + expect(sendBeaconSpy).toHaveBeenCalledWith(`${MOCK_API_URL}/client-logs`, expect.any(Blob)); + const blob = sendBeaconSpy.mock.calls[0][1] as Blob; + expect(blob.type).toBe('application/json'); + expect(blob.size).toBeGreaterThan(0); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.ts new file mode 100644 index 00000000..71bdeda9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/backendLogTransport.ts @@ -0,0 +1,84 @@ +import { API_URL } from '../../utils/constants'; +import isErrorLike from '@common/assertions/isErrorLike'; +import type { ClientLogEvent } from '@common/types'; +import { formatToStore } from 'json-storage-formatter'; +import { createClientEvent } from '../event'; + +/** + * Transport that POSTs ClientLogEvent to the backend. + * Uses sendBeacon for fire-and-forget telemetry; falls back to fetch with keepalive if unavailable. + */ +export class BackendLogTransport { + private readonly endpoint = `${API_URL}/client-logs`; + + log(event: string, extra?: Record): void { + const { sessionId, connectionId, partnerId, userId, ...rest } = (extra ?? {}) as Record< + string, + string | undefined + >; + const clientEvent = { + ...createClientEvent({ + level: 'info', + action: event, + sessionId, + connectionId, + partnerId, + userId, + payload: rest, + }), + variation: 'Success', + }; + this.send(clientEvent); + } + + reportError(error: unknown, extra?: Record): void { + const { sessionId, connectionId, partnerId, userId, ...rest } = (extra ?? {}) as Record< + string, + string | undefined + >; + const errorPayload = { + error: isErrorLike(error) + ? { + message: (error as Error).message, + name: (error as Error).name, + stack: (error as Error).stack, + } + : error, + ...rest, + }; + const clientEvent = { + ...createClientEvent({ + level: 'error', + action: 'Error', + sessionId, + connectionId, + partnerId, + userId, + payload: errorPayload, + }), + variation: isErrorLike(error) ? ((error as Error).name ?? 'Error') : 'Failure', + }; + this.send(clientEvent); + } + + private send(event: ClientLogEvent): void { + let body: string; + try { + body = formatToStore(event); + } catch { + body = '{"error":"[unserializable]"}'; + } + + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon(this.endpoint, new Blob([body], { type: 'application/json' })); + return; + } + + fetch(this.endpoint, { + method: 'POST', + body, + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + }).catch(() => {}); + } +} diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/index.ts b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/index.ts new file mode 100644 index 00000000..bc67ba29 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/logger/transport/index.ts @@ -0,0 +1 @@ +export { BackendLogTransport } from './backendLogTransport'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/main.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/main.tsx new file mode 100644 index 00000000..05353f3f --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/main.tsx @@ -0,0 +1,30 @@ +// runs interceptors before vonage sdk initialize resources (XHR, navigator.mediaDevices clones, etc) +import '@core/interceptors'; + +// executes application setup before anything else (e.g. overrides, mocks, etc) +import './setup'; + +import ReactDOM from 'react-dom/client'; +import { registerIcon } from '@vonage/vivid'; +import App, { loggerProvider } from './App.jsx'; +import './i18n.js'; +import Logger from './logger'; + +// Register Vivid icons for use throughout the application +registerIcon(); + +/** + * The root HTML element where the React application is rendered. + * This element must exist in the DOM for the application to mount correctly. + */ +const rootElement = document.getElementById('root')!; + +const { onUncaughtError, onRecoverableError, onCaughtError } = Logger; + +Logger.setup(() => loggerProvider); + +ReactDOM.createRoot(rootElement, { + onUncaughtError, + onRecoverableError, + onCaughtError, +}).render(); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.spec.tsx new file mode 100644 index 00000000..b14f656e --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.spec.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import BrowserRouter from '@test/RouterWrapper'; +import ErrorPage from './ErrorPage'; + +describe('ErrorPage', () => { + it('should render', () => { + render( + + + + ); + + expect(screen.getByTestId('error-page')).toBeInTheDocument(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.tsx new file mode 100644 index 00000000..40fa2ea9 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/ErrorPage.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ErrorPageProps = { + error: Error; +}; + +/** + * ErrorPage + * + * Displayed by the root ErrorBoundary when the application throws an + * unrecoverable error (e.g. an invalid environment variable in env.ts). + * + * IMPORTANT: intentionally self-contained — must NOT import any component + * that transitively reads `env`, because `env` may be the broken `{} as Env` + * shell when this page is rendered (e.g. Banner → BannerLanguage → LanguageSelector → env). + * @param {ErrorPageProps} props + * @returns {ReactElement} + */ +const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { + const { t } = useTranslation(); + + return ( +
        +
        +

        {t('errorPage.title')}

        + +

        {t('errorPage.description')}

        + +
        +          {error.message}
        +        
        +
        +
        + ); +}; + +export default ErrorPage; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/index.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/index.tsx new file mode 100644 index 00000000..e91e3b7d --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/ErrorBoundary/index.tsx @@ -0,0 +1 @@ +export { default as ErrorPage } from './ErrorPage'; diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.spec.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.spec.tsx new file mode 100644 index 00000000..4881431b --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.spec.tsx @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import BrowserRouter from '@test/RouterWrapper'; +import GoodBye from './index'; +import useArchives from '../../hooks/useArchives'; +import { availableArchive, failedArchive, pendingArchive } from '../../api/archiving/tests/data'; + +vi.mock('../../hooks/useArchives'); +const mockUseArchives = useArchives as Mock<[], ReturnType>; + +vi.mock('react-router-dom', async () => { + const mod = await vi.importActual('react-router-dom'); + return { + ...mod, + useLocation: () => ({}), + }; +}); + +describe('GoodBye', () => { + beforeEach(() => { + mockUseArchives.mockReturnValue({ + data: { + items: [], + count: 0, + }, + error: null, + } as unknown as ReturnType); + }); + + it('should render', () => { + render( + + + + ); + + expect(screen.getByText('You have left the meeting')).toBeVisible(); + expect(screen.getByText('Thank you for joining!')).toBeVisible(); + }); + + it('should fetch and display archives', () => { + mockUseArchives.mockReturnValue({ + data: { items: [availableArchive, failedArchive, pendingArchive] }, + error: null, + } as unknown as ReturnType); + render( + + + + ); + expect(screen.getByText('We are processing your recording')).toBeVisible(); + expect(screen.getByTestId('archive-loading-spinner')).toBeVisible(); + }); + + it('should display "Rejoining the room" button', () => { + render( + + + + ); + expect(screen.getByText('Rejoining the room')).toBeVisible(); + }); + + it('should display "Go to landing page" button', () => { + render( + + + + ); + expect(screen.getByText('View Landing Page')).toBeVisible(); + }); + + it('should display empty message when there are no archives', () => { + mockUseArchives.mockReturnValue({ data: { items: [] }, error: null } as unknown as ReturnType< + typeof useArchives + >); + render( + + + + ); + expect(screen.getByText("The meeting hasn't been recorded")).toBeVisible(); + }); + + it('should display error message when archives fail to load', () => { + mockUseArchives.mockReturnValue({ + data: null, + error: new Error('Failed'), + } as unknown as ReturnType); + render( + + + + ); + expect( + screen.getByText('There was an error loading recordings for this meeting') + ).toBeVisible(); + }); + + it('should display available archive with download button', () => { + mockUseArchives.mockReturnValue({ + data: { items: [availableArchive] }, + error: null, + } as unknown as ReturnType); + render( + + + + ); + const listItem = screen.getByTestId(`archive-list-item-${availableArchive.id}`); + expect(listItem).toBeVisible(); + expect(screen.getByTestId('archive-download-button')).toBeVisible(); + }); + + it('should display failed archive with error icon', () => { + mockUseArchives.mockReturnValue({ + data: { items: [failedArchive] }, + error: null, + } as unknown as ReturnType); + render( + + + + ); + expect(screen.getByTestId('archive-error-icon')).toBeVisible(); + }); + + it('should display pending archive with loading spinner', () => { + mockUseArchives.mockReturnValue({ + data: { items: [pendingArchive] }, + error: null, + } as unknown as ReturnType); + render( + + + + ); + expect(screen.getByTestId('archive-loading-spinner')).toBeVisible(); + }); + + it('should display recordings section title', () => { + mockUseArchives.mockReturnValue({ + data: { items: [availableArchive] }, + error: null, + } as unknown as ReturnType); + render( + + + + ); + expect(screen.getByText('Download recordings')).toBeVisible(); + }); +}); diff --git a/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.tsx b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.tsx new file mode 100644 index 00000000..bbdde562 --- /dev/null +++ b/tutorials/vonage_video_ios_app-theme-customization/project/frontend/src/pages/GoodBye/GoodBye.tsx @@ -0,0 +1,63 @@ +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import PageLayout from '@ui/PageLayout'; +import Banner from '@components/Banner'; +import Footer from '@components/Footer/Footer'; +import ArchiveList from '../../components/GoodBye/ArchiveList'; +import GoodByeMessage from '../../components/GoodBye/GoodbyeMessage'; +import ReenterRoomButton from '@components/GoodBye/ReenterRoomButton'; +import GoToLandingPageButton from '@components/GoodBye/GoToLandingPageButton'; +import Card from '@ui/Card'; +import useGoodByePage from '../../hooks/useGoodByePage'; + +/** + * GoodBye Component + * + * This component displays a goodbye message when a user leaves the meeting room. + * It shows a banner, a set of salutations, and two buttons: + * - One to re-enter the room + * - One to go back to the landing page + * It also shows a list of archives available for download (if applicable). + * @returns {ReactElement} - the goodbye page. + */ +const GoodBye = (): ReactElement => { + const { t } = useTranslation(); + const { archives, header, caption, isSelfDeclinedRecording } = useGoodByePage(); + + return ( + + + + + + + + +
        + +

        + {t('goodBye.title')} +

        +
        + +
        + +
        + {!isSelfDeclinedRecording && ( + +

        + {t('archiveList.label')} +

        + +
        + )} +
        +
        + +