diff --git a/README.md b/README.md index ed772906..7ada0b98 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,12 @@ Authenticated viewers are able to read and send messages. Unauthenticated users #### Stream overlays -Streamers can trigger various stream overlays: host a quiz, feature a product, feature an Amazon product, show a notice and trigger a celebration. More information on how overlays are triggered by streamers is available here: [Trigger overlays](#stream-overlay-configuration). +Streamers can trigger various stream overlays: host a quiz, host a poll, feature a product, feature an Amazon product, show a notice and trigger a celebration. More information on how overlays are triggered by streamers is available here: [Trigger overlays](#stream-overlay-configuration). ![Quiz action](screenshots/features/action-quiz.png) +![Poll action](screenshots/features/action-poll.png) + ![Product action](screenshots/features/action-product.png) ![Amazon Product action](screenshots/features/action-amazon-product.png) @@ -94,10 +96,16 @@ Streamers can trigger various stream overlays: host a quiz, feature a product, f ![Celebration action](screenshots/features/action-celebration.png) -The stream actions are received by the viewers through the IVS Player using [Timed Metadata](https://docs.aws.amazon.com/ivs/latest/userguide/metadata.html). +All stream actions (with the exception of hosting a poll) are received by the viewers through the IVS Player using [Timed Metadata](https://docs.aws.amazon.com/ivs/latest/userguide/metadata.html). ![Viewer stream actions architecture](screenshots/architecture/receive-stream-actions.png) +The poll stream overlay action on the otherhand leverages the IVS Chat Messaging [SDK](https://aws.github.io/amazon-ivs-chat-messaging-sdk-js/1.0.2/) to receive and emit different poll action events. + +Note: The processing of votes happens on the streamer's side while they are on the stream manager page. Navigating away from this page will effectively skip votes. + +![Poll stream action architecture](screenshots/architecture//poll.png) + ### Stream health monitoring The Stream Health page is only accessible to authenticated users, from the `/health` URL. It enables streamers to monitor live and past stream sessions. For each session, the page will show the stream events, the video bitrate and frame rate in the form of charts and a summary of the encoder configuration at the time of go-live. [Learn more](https://docs.aws.amazon.com/ivs/latest/userguide/stream-health.html) @@ -465,6 +473,64 @@ Testing is automated using two GitHub Actions workflows: one for running the bac See [Api Rates](https://webservices.amazon.com/paapi5/documentation/troubleshooting/api-rates.html) for more information. +## Services Used + +Below is a list of the all the services used for the UGC Demo. + +- [API Gateway](https://aws.amazon.com/api-gateway/pricing/) +- [CloudFront](https://aws.amazon.com/cloudfront/pricing/) +- [CloudWatch Logs](https://aws.amazon.com/cloudwatch/pricing/) +- [Cognito](https://aws.amazon.com/cognito/pricing/) +- [DynamoDB](https://aws.amazon.com/dynamodb/pricing/on-demand/) +- [Elastic Container Registry](https://aws.amazon.com/ecr/pricing/) +- [Elastic Container Service](https://aws.amazon.com/fargate/pricing/) +- [EventBridge](https://aws.amazon.com/eventbridge/pricing/) +- [Lambda](https://aws.amazon.com/lambda/pricing/) +- [Secrets Manager](https://aws.amazon.com/secrets-manager/pricing/) +- [Interactive Video Service](https://aws.amazon.com/ivs/pricing/) + +
+ +The following is a detailed usage-based summary. Use it as a guide to estimate project costs. + +
+Click here to view details + +### Overall Usage + +| Service | 1 user | 10 users | 100 users | +| -------------------------------------------------------------------- | -----: | -------: | --------: | +| Total number of requests in a month (average request size 5kB): | 1 request/second | 10 request/second | 100 request/second | +| [API Gateway](https://aws.amazon.com/api-gateway/pricing/) | 2,592,000 | 25,920,000 | 259,200,000 | +| Homepage size is 317B. Total of GB downloaded from visits to the homepage: | 1 request/second (GB) | 10 request/second (GB) | 100 request/second (GB) +| [CloudFront](https://aws.amazon.com/cloudfront/pricing/) | 0.82 | 8.22 | 82.17 | +| 1KB of log per request. Total GB of logs generated by traffic: | 1 request/second (GB) | 10 request/second (GB) | 100 request/second (GB) | +| [CloudWatch Logs](https://aws.amazon.com/cloudwatch/pricing/) | 2.59 | 25.92 | 259.20 | +| Monthly Active Users (MAU). With no advanced features. No SAML or OIDC Auth: | Number of MAU | Number of MAU | Number of MAU | +| [Cognito](https://aws.amazon.com/cognito/pricing/) | 50,000 | 100,000 | 1,000,000 | +| Average item size 105 Bytes. Asumming Monthly Active Users. Each user going live once a day: | MAU 50,000 (GB) | MAU 100,000 (GB) | MAU 1,000,000 (GB) | +| [DynamoDB](https://aws.amazon.com/dynamodb/pricing/on-demand/) | 0.01 | 0.01 | 0.11 | +| Average build size 103.01 MB: | 1 deployment a day for a month (GB) | 10 deployment a day for a month (GB) | 100 deployment a day for a month (GB) | +| [Elastic Container Registry](https://aws.amazon.com/ecr/pricing/) | 0.10 | 1.03 | 10.30 | +| Number of x86 pods with 0.25vCPU and 512 RAM. 20GB ephemeral storage: | 1 request/second | 10 request/second | 100 request/second | +| [Elastic Container Service](https://aws.amazon.com/fargate/pricing/) | 1 | 1 | 2 | +| Total number of requests in a month that go to API destinations: | 1 request/second | 10 request/second | 100 request/second | +| [EventBridge](https://aws.amazon.com/eventbridge/pricing/) | 2,592,000 | 25,920,000 | 259,200,000 | +| Total number of requests in a month ( 0.125GB memory allocated and 0.5 GB ephemeral storage allocated and 699ms average billable time: | 1 request/second | 10 request/second | 100 request/second | +| [Lambda](https://aws.amazon.com/lambda/pricing/) | 2,592,000 | 25,920,000 | 259,200,000 | +| Total number of requests in a month. We have 3 secrets in the manager: | 1 request/second | 10 request/second | 100 request/second | +| [Secrets Manager](https://aws.amazon.com/secrets-manager/pricing/) | 2,592,000 | 25,920,000 | 259,200,000 | + +### Amazon IVS usage + +| Service | Based on | Based on | Based on | +| -------------------------------------------------------------------- | -----: | -------: | --------: | +| [Interactive Video Service](https://aws.amazon.com/ivs/pricing/) Low-latency streaming input | Hours streamed | Hours streamed | Hours streamed | +| [Interactive Video Service](https://aws.amazon.com/ivs/pricing/) Low-latency streaming output | Hours watched | Hours watched | Hours watched | +| [Interactive Video Service](https://aws.amazon.com/ivs/pricing/) Chat | Chat usage | Chat usage | Chat usage | + + +
## About Amazon IVS diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index fbdc194d..1c99691c 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -103,4 +103,4 @@ You may add Your own copyright statement to Your modifications and may provide a 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 +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/cdk/Makefile b/cdk/Makefile index 7b3d8f01..1641a89b 100644 --- a/cdk/Makefile +++ b/cdk/Makefile @@ -8,7 +8,8 @@ AWS_PROFILE_FLAG = --profile $(AWS_PROFILE) STAGE ?= dev PUBLISH ?= false STACK ?= UGC-$(STAGE) -CDK_OPTIONS = $(if $(AWS_PROFILE),$(AWS_PROFILE_FLAG)) -c stage=$(STAGE) -c publish=$(PUBLISH) -c stackName=$(STACK) +SCHEDULE ?= rate(48 hours) +CDK_OPTIONS = $(if $(AWS_PROFILE),$(AWS_PROFILE_FLAG)) -c stage=$(STAGE) -c publish=$(PUBLISH) -c stackName=$(STACK) -c scheduleExp="$(strip $(SCHEDULE))" FE_DEPLOYMENT_STACK = UGC-Frontend-Deployment-$(STAGE) SEED_COUNT ?= 50 OFFLINE_SESSION_COUNT ?= 1 @@ -25,8 +26,10 @@ help: ## Shows this help message @echo " Option 1: export AWS_PROFILE=user1\n" @echo " Option 2: make AWS_PROFILE=user1\n" @echo "2. Set the STAGE value to \"dev\" or \"prod\" to use the corresponding configuration. The default value is \"dev\". \n" | fold -s - @echo "3. AWS CLI is required to run the seed command (https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html). \n" - @echo "4. Add JSON= as an argument for the seed command to use a JSON file to create data. Furthermore, the script will create randomly generated data to match the SEED_COUNT. By default the script will create 1 offline session count out of the seed count. To change this please use the OFFLINE_SESSION_COUNT attribute. \n" + @echo "3. Set the SCHEDULE value to either a cron or rate expression based on the UTC time zone. By default, a rate expression is used to run the cleanupUnverifiedUsers Lambda every 48 hours. \n" | fold -s" + @echo "Read more about schedule expressions for EventBridge rules here: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html \n" | fold -s + @echo "4. AWS CLI is required to run the seed command (https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html). \n" + @echo "5. Add JSON= as an argument for the seed command to use a JSON file to create data. Furthermore, the script will create randomly generated data to match the SEED_COUNT. By default the script will create 1 offline session count out of the seed count. To change this please use the OFFLINE_SESSION_COUNT attribute. \n" app: install bootstrap deploy ## Installs NPM dependencies, bootstraps, and deploys the stack diff --git a/cdk/bin/cdk.ts b/cdk/bin/cdk.ts index 130af801..a3c8aaa2 100644 --- a/cdk/bin/cdk.ts +++ b/cdk/bin/cdk.ts @@ -12,6 +12,7 @@ const app = new App(); const stage = app.node.tryGetContext('stage'); const stackName = app.node.tryGetContext('stackName'); const shouldPublish = app.node.tryGetContext('publish') === 'true'; +const scheduleExp = app.node.tryGetContext('scheduleExp'); // Get the config for the current stage const { resourceConfig }: { resourceConfig: UGCResourceWithChannelsConfig } = app.node.tryGetContext(stage); @@ -22,7 +23,8 @@ new UGCStack(app, stackName, { env: { account, region }, tags: { stage, project: 'ugc' }, resourceConfig, - shouldPublish + shouldPublish, + scheduleExp }); new UGCFrontendDeploymentStack(app, `UGC-Frontend-Deployment-${stage}`, { diff --git a/cdk/lambdas/cleanupUnverifiedUsers.ts b/cdk/lambdas/cleanupUnverifiedUsers.ts new file mode 100644 index 00000000..c2425af1 --- /dev/null +++ b/cdk/lambdas/cleanupUnverifiedUsers.ts @@ -0,0 +1,131 @@ +import { + AdminDeleteUserCommand, + ListUsersCommand, + UserType +} from '@aws-sdk/client-cognito-identity-provider'; +import { WriteRequest } from '@aws-sdk/client-dynamodb'; +import { marshall } from '@aws-sdk/util-dynamodb'; + +import { + batchDeleteItemsWithRetry, + cognitoClient, + convertToChunks +} from './helpers'; + +const { CHANNELS_TABLE_NAME: channelsTableName, USER_POOL_ID: userPoolId } = + process.env; + +export const handler = async () => { + try { + if (!channelsTableName || !userPoolId) + throw new Error( + 'Missing required variables: channelsTableName or userPoolId are not defined.' + ); + + const deletedCognitoUserSubs: string[] = []; + let listUnconfirmedUsers: UserType[] = []; + let paginationToken: string | undefined; + + const getUnconfirmedUsers = async () => { + const listUnconfirmedUsersCommand = new ListUsersCommand({ + UserPoolId: userPoolId, + Filter: 'cognito:user_status ="UNCONFIRMED"', + PaginationToken: paginationToken, + AttributesToGet: ['sub'] + }); + + const listUnconfirmedUsersResponse = await cognitoClient.send( + listUnconfirmedUsersCommand + ); + + if (listUnconfirmedUsersResponse?.Users) { + listUnconfirmedUsers = [ + ...listUnconfirmedUsers, + ...listUnconfirmedUsersResponse.Users + ]; + } + paginationToken = + listUnconfirmedUsersResponse.PaginationToken || undefined; + + if (paginationToken) await getUnconfirmedUsers(); + }; + + await getUnconfirmedUsers(); + + if (listUnconfirmedUsers.length === 0) return; + + // Filter users created at least 24 hours ago + const expiredUnconfirmedCognitoUsers = + listUnconfirmedUsers.filter((cognitoUser) => { + const { UserCreateDate = '' } = cognitoUser; + if (!UserCreateDate) return false; + const millisecondsInOneDay = 60 * 60 * 24 * 1000; + + const timeElapsedSinceCreation = + new Date().getTime() - new Date(UserCreateDate).getTime(); + + return Math.abs(timeElapsedSinceCreation) > millisecondsInOneDay; + }) || []; + + if (expiredUnconfirmedCognitoUsers.length === 0) return; + + // Delete unverified Cognito users created 24 hours or more ago in parallel + const deleteCognitoUserPromises = expiredUnconfirmedCognitoUsers.map( + ({ Username, Attributes }) => { + const subAttribute = Attributes?.find( + (attribute) => attribute.Name === 'sub' + ); + + return new Promise(async (resolve, rejects) => { + try { + if (!Username || !subAttribute || !subAttribute.Value) return; + + const deleteUserCommand = new AdminDeleteUserCommand({ + UserPoolId: userPoolId, + Username + }); + const response = await cognitoClient.send(deleteUserCommand); + deletedCognitoUserSubs.push(subAttribute.Value); + resolve(response); + } catch (err) { + console.error(err); + rejects({}); + } + }); + } + ); + await Promise.allSettled(deleteCognitoUserPromises); + + if (deletedCognitoUserSubs.length) { + // Batch delete a maximum of 25 items at a time from DynamoDB. + const deleteRequests = deletedCognitoUserSubs.reduce((acc, userSubs) => { + return [ + ...acc, + { + DeleteRequest: { + Key: marshall({ + id: userSubs + }) + } + } + ]; + }, [] as WriteRequest[]); + + const deleteRequestChunks = convertToChunks(deleteRequests, 25); + + for (const chunkIndex in deleteRequestChunks) { + await batchDeleteItemsWithRetry({ + [channelsTableName]: deleteRequestChunks[chunkIndex] + }); + } + } + } catch (error) { + console.error(error); + + throw new Error( + 'Failed to remove unverified users due to unexpected error' + ); + } +}; + +export default handler; diff --git a/cdk/lambdas/helpers.ts b/cdk/lambdas/helpers.ts index eb5aad35..2a301806 100644 --- a/cdk/lambdas/helpers.ts +++ b/cdk/lambdas/helpers.ts @@ -1,6 +1,13 @@ import { convertToAttr } from '@aws-sdk/util-dynamodb'; -import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { + BatchWriteItemCommand, + DynamoDBClient, + QueryCommand, + WriteRequest +} from '@aws-sdk/client-dynamodb'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +export const cognitoClient = new CognitoIdentityProviderClient({}); export const dynamoDbClient = new DynamoDBClient({}); export const getChannelByChannelAssetId = (channelAssetId: string) => { @@ -23,3 +30,42 @@ export const isRejected = ( export const isFulfilled = ( input: PromiseSettledResult ): input is PromiseFulfilledResult => input.status === 'fulfilled'; + +export const convertToChunks = ( + array: object[], + chunkSize: number +): { [key: number]: typeof array } => { + const result: { [key: number]: typeof array } = {}; + let keyIndex = 0; + + for (let i = 0; i < array.length; i += chunkSize) { + const chunk: object[] = array.slice(i, i + chunkSize); + result[keyIndex] = chunk; + keyIndex += 1; + } + + return result; +}; + +export const batchDeleteItemsWithRetry = async ( + requestItems: Record | undefined, + retryCount = 0, + maxRetries = 4 +): Promise => { + const batchWriteCommandInput = { + RequestItems: requestItems + }; + const batchWriteCommand = new BatchWriteItemCommand(batchWriteCommandInput); + const response = await dynamoDbClient.send(batchWriteCommand); + + if (response.UnprocessedItems && response.UnprocessedItems.length) { + if (retryCount > maxRetries) + throw new Error( + `Failed to batch delete AWS DynamoDB items: ${response.UnprocessedItems}` + ); + + await new Promise((resolve) => setTimeout(resolve, 2 ** retryCount * 10)); + + return batchDeleteItemsWithRetry(response.UnprocessedItems, retryCount + 1); + } +}; diff --git a/cdk/lib/ChannelsStack/cdk-channels-stack.ts b/cdk/lib/ChannelsStack/cdk-channels-stack.ts index 0d20b61a..3d831597 100644 --- a/cdk/lib/ChannelsStack/cdk-channels-stack.ts +++ b/cdk/lib/ChannelsStack/cdk-channels-stack.ts @@ -3,7 +3,11 @@ import { aws_cloudfront_origins as origins, aws_cognito as cognito, aws_dynamodb as dynamodb, + aws_events as events, + aws_events_targets as targets, aws_iam as iam, + aws_lambda as lambda, + aws_lambda_nodejs as nodejsLambda, aws_s3 as s3, aws_s3_notifications as s3n, Duration, @@ -24,10 +28,15 @@ import { import ChannelsCognitoTriggers from './Constructs/ChannelsCognitoTriggers'; import SQSLambdaTrigger from '../Constructs/SQSLambdaTrigger'; import { SECRET_IDS } from '../../api/shared/constants'; +import { join } from 'path'; + +const getLambdaEntryPath = (functionName: string) => + join(__dirname, '../../lambdas', `${functionName}.ts`); interface ChannelsStackProps extends NestedStackProps { resourceConfig: ChannelsResourceConfig; tags: { [key: string]: string }; + scheduleExp: string; } export class ChannelsStack extends NestedStack { @@ -46,7 +55,7 @@ export class ChannelsStack extends NestedStack { const parentStackName = Stack.of(this.nestedStackParent!).stackName; const nestedStackName = 'Channels'; const stackNamePrefix = `${parentStackName}-${nestedStackName}`; - const { resourceConfig, tags } = props; + const { resourceConfig, scheduleExp, tags } = props; // Configuration variables based on the stage (dev or prod) const { @@ -380,6 +389,47 @@ export class ChannelsStack extends NestedStack { ); this.policies = policies; + // Cleanup unverified users policies + const deleteUnverifiedChannelsPolicyStatement = new iam.PolicyStatement({ + actions: ['dynamodb:BatchWriteItem'], + effect: iam.Effect.ALLOW, + resources: [channelsTable.tableArn] + }); + const deleteUnverifiedUserPolicyStatement = new iam.PolicyStatement({ + actions: ['cognito-idp:AdminDeleteUser', 'cognito-idp:ListUsers'], + effect: iam.Effect.ALLOW, + resources: [userPool.userPoolArn] + }); + + // Cleanup unverified users lambda + const cleanupUnverifiedUsersHandler = new nodejsLambda.NodejsFunction( + this, + `${stackNamePrefix}-CleanupUnverifiedUsers-Handler`, + { + logRetention: 7, + runtime: lambda.Runtime.NODEJS_16_X, + bundling: { minify: true }, + functionName: `${stackNamePrefix}-CleanupUnverifiedUsers`, + entry: getLambdaEntryPath('cleanupUnverifiedUsers'), + timeout: Duration.minutes(10), + initialPolicy: [ + deleteUnverifiedUserPolicyStatement, + deleteUnverifiedChannelsPolicyStatement + ] + } + ); + + // Scheduled cleanup unverified users lambda function + new events.Rule(this, 'Cleanup-Unverified-Users-Schedule-Rule', { + schedule: events.Schedule.expression(scheduleExp), + ruleName: `${stackNamePrefix}-CleanupUnverifiedUsers-Schedule`, + targets: [ + new targets.LambdaFunction(cleanupUnverifiedUsersHandler, { + maxEventAge: Duration.minutes(2) + }) + ] + }); + const containerEnv = { CHANNEL_ASSETS_BUCKET_NAME: channelAssetsBucket.bucketName, CHANNELS_TABLE_NAME: channelsTable.tableName, diff --git a/cdk/lib/cdk-ugc-stack.ts b/cdk/lib/cdk-ugc-stack.ts index 9478a96c..9d7fc0e3 100644 --- a/cdk/lib/cdk-ugc-stack.ts +++ b/cdk/lib/cdk-ugc-stack.ts @@ -27,6 +27,7 @@ const DEFAULT_CLIENT_BASE_URLS = ['', 'http://localhost:3000']; interface UGCDashboardStackProps extends StackProps { resourceConfig: UGCResourceWithChannelsConfig; + scheduleExp: string; shouldPublish: boolean; } @@ -34,7 +35,7 @@ export class UGCStack extends Stack { constructor(scope: Construct, id: string, props: UGCDashboardStackProps) { super(scope, id, props); - const { resourceConfig, shouldPublish, tags = {} } = props; + const { resourceConfig, scheduleExp, shouldPublish, tags = {} } = props; const { deploySeparateContainers, ivsChannelType, @@ -134,6 +135,7 @@ export class UGCStack extends Stack { // Channels Stack const channelsStack = new ChannelsStack(this, 'Channels', { resourceConfig, + scheduleExp, tags }); const { diff --git a/cdk/package-lock.json b/cdk/package-lock.json index 4e6ce06e..4c9691f3 100644 --- a/cdk/package-lock.json +++ b/cdk/package-lock.json @@ -8,6 +8,7 @@ "name": "cdk", "version": "0.1.0", "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.363.0", "@aws-sdk/client-ivs": "^3.354.0", "aws-cdk-lib": "2.45.0", "cdk-ecr-deployment": "^2.5.6", @@ -152,6 +153,542 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-cognito-identity-provider": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.363.0.tgz", + "integrity": "sha512-TkwUy5IJT021CQE3+mnEEnOcoxkNrIv/T0dXiYGivx+xt06PpjbxOY8S1UDa3oZ9zZ2ZR5v6v7AfcA58lwImPA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.363.0", + "@aws-sdk/credential-provider-node": "3.363.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/ie11-detection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", + "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/sha256-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", + "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/sha256-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", + "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", + "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/client-sso": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.363.0.tgz", + "integrity": "sha512-PZ+HfKSgS4hlMnJzG+Ev8/mgHd/b/ETlJWPSWjC/f2NwVoBQkBnqHjdyEx7QjF6nksJozcVh5Q+kkYLKc/QwBQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.363.0.tgz", + "integrity": "sha512-V3Ebiq/zNtDS/O92HUWGBa7MY59RYSsqWd+E0XrXv6VYTA00RlMTbNcseivNgp2UghOgB9a20Nkz6EqAeIN+RQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/client-sts": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.363.0.tgz", + "integrity": "sha512-0jj14WvBPJQ8xr72cL0mhlmQ90tF0O0wqXwSbtog6PsC8+KDE6Yf+WsxsumyI8E5O8u3eYijBL+KdqG07F/y/w==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/credential-provider-node": "3.363.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-sdk-sts": "3.363.0", + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.1", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/smithy-client": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.363.0.tgz", + "integrity": "sha512-VAQ3zITT2Q0acht0HezouYnMFKZ2vIOa20X4zQA3WI0HfaP4D6ga6KaenbDcb/4VFiqfqiRHfdyXHP0ThcDRMA==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.363.0.tgz", + "integrity": "sha512-ZYN+INoqyX5FVC3rqUxB6O8nOWkr0gHRRBm1suoOlmuFJ/WSlW/uUGthRBY5x1AQQnBF8cpdlxZzGHd41lFVNw==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.363.0", + "@aws-sdk/credential-provider-process": "3.363.0", + "@aws-sdk/credential-provider-sso": "3.363.0", + "@aws-sdk/credential-provider-web-identity": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.363.0.tgz", + "integrity": "sha512-C1qXFIN2yMxD6pGgug0vR1UhScOki6VqdzuBHzXZAGu7MOjvgHNdscEcb3CpWnITHaPL2ztkiw75T1sZ7oIgQg==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.363.0", + "@aws-sdk/credential-provider-ini": "3.363.0", + "@aws-sdk/credential-provider-process": "3.363.0", + "@aws-sdk/credential-provider-sso": "3.363.0", + "@aws-sdk/credential-provider-web-identity": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.363.0.tgz", + "integrity": "sha512-fOKAINU7Rtj2T8pP13GdCt+u0Ml3gYynp8ki+1jMZIQ+Ju/MdDOqZpKMFKicMn3Z1ttUOgqr+grUdus6z8ceBQ==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.363.0.tgz", + "integrity": "sha512-5RUZ5oM0lwZSo3EehT0dXggOjgtxFogpT3cZvoLGtIwrPBvm8jOQPXQUlaqCj10ThF1sYltEyukz/ovtDwYGew==", + "dependencies": { + "@aws-sdk/client-sso": "3.363.0", + "@aws-sdk/token-providers": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.363.0.tgz", + "integrity": "sha512-Z6w7fjgy79pAax580wdixbStQw10xfyZ+hOYLcPudoYFKjoNx0NQBejg5SwBzCF/HQL23Ksm9kDfbXDX9fkPhA==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.363.0.tgz", + "integrity": "sha512-FobpclDCf5Y1ueyJDmb9MqguAdPssNMlnqWQpujhYVABq69KHu73fSCWSauFPUrw7YOpV8kG1uagDF0POSxHzA==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-logger": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.363.0.tgz", + "integrity": "sha512-SSGgthScYnFGTOw8EzbkvquqweFmvn7uJihkpFekbtBNGC/jGOGO+8ziHjTQ8t/iI/YKubEwv+LMi0f77HKSEg==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.363.0.tgz", + "integrity": "sha512-MWD/57QgI/N7fG8rtzDTUdSqNpYohQfgj9XCFAoVeI/bU4usrkOrew43L4smJG4XrDxlNT8lSJlDtd64tuiUZA==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-sdk-sts": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.363.0.tgz", + "integrity": "sha512-1yy2Ac50FO8BrODaw5bPWvVrRhaVLqXTFH6iHB+dJLPUkwtY5zLM3Mp+9Ilm7kME+r7oIB1wuO6ZB1Lf4ZszIw==", + "dependencies": { + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-signing": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.363.0.tgz", + "integrity": "sha512-/7qia715pt9JKYIPDGu22WmdZxD8cfF/5xB+1kmILg7ZtjO0pPuTaCNJ7xiIuFd7Dn7JXp5lop08anX/GOhNRQ==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/signature-v4": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-middleware": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.363.0.tgz", + "integrity": "sha512-ri8YaQvXP6odteVTMfxPqFR26Q0h9ejtqhUDv47P34FaKXedEM4nC6ix6o+5FEYj6l8syGyktftZ5O70NoEhug==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/token-providers": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.363.0.tgz", + "integrity": "sha512-6+0aJ1zugNgsMmhTtW2LBWxOVSaXCUk2q3xyTchSXkNzallYaRiZMRkieW+pKNntnu0g5H1T0zyfCO0tbXwxEA==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/types": { + "version": "3.357.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.357.0.tgz", + "integrity": "sha512-/riCRaXg3p71BeWnShrai0y0QTdXcouPSM0Cn1olZbzTf7s71aLEewrc96qFrL70XhY4XvnxMpqQh+r43XIL3g==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/util-endpoints": { + "version": "3.357.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.357.0.tgz", + "integrity": "sha512-XHKyS5JClT9su9hDif715jpZiWHQF9gKZXER8tW0gOizU3R9cyWc9EsJ2BRhFNhi7nt/JF/CLUEc5qDx3ETbUw==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.363.0.tgz", + "integrity": "sha512-fk9ymBUIYbxiGm99Cn+kAAXmvMCWTf/cHAcB79oCXV4ELXdPa9lN5xQhZRFNxLUeXG4OAMEuCAUUuZEj8Fnc1Q==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.363.0.tgz", + "integrity": "sha512-Fli/dvgGA9hdnQUrYb1//wNSFlK2jAfdJcfNXA6SeBYzSeH5pVGYF4kXF0FCdnMA3Fef+Zn1zAP/hw9v8VJHWQ==", + "dependencies": { + "@aws-sdk/types": "3.357.0", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-cognito-identity-provider/node_modules/fast-xml-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", + "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.216.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.216.0.tgz", @@ -3079,30 +3616,491 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@smithy/protocol-http": { + "node_modules/@smithy/abort-controller": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", - "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-1.0.1.tgz", + "integrity": "sha512-An6irzp9NCji2JtJHhrEFlDbxLwHd6c6Y9fq3ZeomyUR8BIXlGXVTxsemUSZVVgOq3166iYbYs/CrPAmgRSFLw==", "dependencies": { - "@smithy/types": "^1.0.0", + "@smithy/types": "^1.1.0", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@smithy/types": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", - "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", + "node_modules/@smithy/config-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-1.0.1.tgz", + "integrity": "sha512-quj0xUiEVG/UHfY82EtthR/+S5/17p3IxXArC3NFSNqryMobWbG9oWgJy2s2cgUSVZLzxevjKKvxrilK7JEDaA==", "dependencies": { + "@smithy/types": "^1.1.0", + "@smithy/util-config-provider": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tsconfig/node10": { + "node_modules/@smithy/credential-provider-imds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-1.0.1.tgz", + "integrity": "sha512-hkRJoxVCh4CEt1zYOBElE+G/MV6lyx3g68hSJpesM4pwMT/bzEVo5E5XzXY+6dVq8yszeatWKbFuqCCBQte8tg==", + "dependencies": { + "@smithy/node-config-provider": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-1.0.1.tgz", + "integrity": "sha512-cpcTXQEOEs2wEvIyxW/iTHJ2m0RVqoEOTjjWEXD6SY8Gcs3FCFP6E8MXadC098tdH5ctMIUXc8POXyMpxzGnjw==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^1.1.0", + "@smithy/util-hex-encoding": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-1.0.1.tgz", + "integrity": "sha512-/e2A8eOMk4FVZBQ0o6uF/ttLtFZcmsK5MIwDu1UE3crM4pCAIP19Ul8U9rdLlHhIu81X4AcJmSw55RDSpVRL/w==", + "dependencies": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/querystring-builder": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-base64": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-1.0.1.tgz", + "integrity": "sha512-eCz08BySBcOjVObjbRAS/XMKUGY4ujnuS+GoWeEpzpCSKDnO8/YQ0rStRt4C0llRmhApizYc1tK9DiJwfvXcBg==", + "dependencies": { + "@smithy/types": "^1.1.0", + "@smithy/util-buffer-from": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-1.0.1.tgz", + "integrity": "sha512-kib63GFlAzRn/wf8M0cRWrZA1cyOy5IvpTkLavCY782DPFMP0EaEeD6VrlNIOvD6ncf7uCJ68HqckhwK1qLT3g==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-1.0.1.tgz", + "integrity": "sha512-fHSTW70gANnzPYWNDcWkPXpp+QMbHhKozbQm/+Denkhp4gwSiPuAovWZRpJa9sXO+Q4dOnNzYN2max1vTCEroA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-1.0.1.tgz", + "integrity": "sha512-vWWigayk5i2cFp9xPX5vdzHyK+P0t/xZ3Ovp4Ss+c8JQ1Hlq2kpJZVWtTKsmdfND5rVo5lu0kD5wgAMUCcmuhw==", + "dependencies": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-1.0.2.tgz", + "integrity": "sha512-F3CyXgjtDI4quGFkDmVNytt6KMwlzzeMxtopk6Edue4uKdKcMC1vUmoRS5xTbFzKDDp4XwpnEV7FshPaL3eCPw==", + "dependencies": { + "@smithy/middleware-serde": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-1.0.3.tgz", + "integrity": "sha512-ZRsjG8adtxQ456FULPqPFmWtrW44Fq8IgdQvQB+rC2RSho3OUzS+TiEIwb5Zs6rf2IoewITKtfdtsUZcxXO0ng==", + "dependencies": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/service-error-classification": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/util-middleware": "^1.0.1", + "@smithy/util-retry": "^1.0.3", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-1.0.1.tgz", + "integrity": "sha512-bn5lWk8UUeXFCQfkrNErz5SbeNd+2hgYegHMLsOLPt4URDIsyREar6wMsdsR+8UCdgR5s8udG3Zalgc7puizIQ==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-1.0.1.tgz", + "integrity": "sha512-T6+gsAO1JYamOJqmORCrByDeQ/NB+ggjHb33UDOgdX4xIjXz/FB/3UqHgQu6PL1cSFrK+i4oteDIwqARDs/Szw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-1.0.1.tgz", + "integrity": "sha512-FRxifH/J2SgOaVLihIqBFuGhiHR/NfzbZYp5nYO7BGgT/gc/f9nAuuRJcEy/hwO3aI6ThyG5apH4tGec6A2sCw==", + "dependencies": { + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-1.0.2.tgz", + "integrity": "sha512-PzPrGRSt3kNuruLCeR4ffJp57ZLVnIukMXVL3Ppr65ZoxiE+HBsOVAa/Z/T+4HzjCM6RaXnnmB8YKfsDjlb0iA==", + "dependencies": { + "@smithy/abort-controller": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/querystring-builder": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-1.0.1.tgz", + "integrity": "sha512-3EG/61Ls1MrgEaafpltXBJHSqFPqmTzEX7QKO7lOEHuYGmGYzZ08t1SsTgd1vM74z0IihoZyGPynZ7WmXKvTeg==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.1.0.tgz", + "integrity": "sha512-H5y/kZOqfJSqRkwtcAoVbqONmhdXwSgYNJ1Glk5Ry8qlhVVy5qUzD9EklaCH8/XLnoCsLO/F/Giee8MIvaBRkg==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-1.0.1.tgz", + "integrity": "sha512-J5Tzkw1PMtu01h6wl+tlN5vsyROmS6/z5lEfNlLo/L4ELHeVkQ4Q0PEIjDddPLfjVLCm8biQTESE5GCMixSRNQ==", + "dependencies": { + "@smithy/types": "^1.1.0", + "@smithy/util-uri-escape": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-1.0.1.tgz", + "integrity": "sha512-zauxdMc3cwxoLitI5DZqH7xN6Fk0mwRxrUMAETbav2j6Se2U0UGak/55rZcDg2yGzOURaLYi5iOm1gHr98P+Bw==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-1.0.2.tgz", + "integrity": "sha512-Q5CCuzYL5FGo6Rr/O+lZxXHm2hrRgbmMn8MgyjqZUWZg20COg20DuNtIbho2iht6CoB7jOpmpBqhWizLlzUZgg==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-1.0.1.tgz", + "integrity": "sha512-EztziuIPoNronENGqh+MWVKJErA4rJpaPzJCPukzBeEoG2USka0/q4B5Mr/1zszOnrb49fPNh4u3u5LfiH7QzA==", + "dependencies": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-1.0.1.tgz", + "integrity": "sha512-2D69je14ou1vBTnAQeysSK4QVMm0j3WHS3MDg/DnHnFFcXRCzVl/xAARO7POD8+fpi4tMFPs8Z4hzo1Zw40L0Q==", + "dependencies": { + "@smithy/eventstream-codec": "^1.0.1", + "@smithy/is-array-buffer": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-hex-encoding": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", + "@smithy/util-uri-escape": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-1.0.3.tgz", + "integrity": "sha512-Wh1mNP/1yUZK0uYkgCQ6NMxpBT3Fmc45TMdUfOlH1xD2zGYL7U4yDHFOhEZdi/suyjaelFobXB2p9pPIw6LjRQ==", + "dependencies": { + "@smithy/middleware-stack": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-stream": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.1.0.tgz", + "integrity": "sha512-KzmvisMmuwD2jZXuC9e65JrgsZM97y5NpDU7g347oB+Q+xQLU6hQZ5zFNNbEfwwOJHoOvEVTna+dk1h/lW7alw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-1.0.1.tgz", + "integrity": "sha512-33vWEtE6HzmwjEcEb4I58XMLRAchwPS93YhfDyXAXr1jwDCzfXmMayQwwpyW847rpWj0XJimxqia8q0z+k/ybw==", + "dependencies": { + "@smithy/querystring-parser": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-1.0.1.tgz", + "integrity": "sha512-rJcpRi/yUi6TyCEkjdTH86/ExBuKlfctEXhG9/4gMJ3/cnPcHJJnr0mQ9evSEO+3DbpT/Nxq90bcTBdTIAmCig==", + "dependencies": { + "@smithy/util-buffer-from": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-1.0.1.tgz", + "integrity": "sha512-Pdp744fmF7E1NWoSb7256Anhm8eYoCubvosdMwXzOnHuPRVbDa15pKUz2027K3+jrfGpXo1r+MnDerajME1Osw==", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-1.0.1.tgz", + "integrity": "sha512-4PIHjDFwG07SNensAiVq/CJmubEVuwclWSYOTNtzBNTvxOeGLznvygkGYgPzS3erByT8C4S9JvnLYgtrsVV3nQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-1.0.1.tgz", + "integrity": "sha512-363N7Wq0ceUgE5lLe6kaR6GlJs2/m4r9V6bRMfIszb6P1FZbbRRM2FQYUWWPFSsRymm9mJL18b3fjiVsIvhDGg==", + "dependencies": { + "@smithy/is-array-buffer": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-1.0.1.tgz", + "integrity": "sha512-4Qy38Oy5/q43MpTwCLV1P+7NeaOp4W2etQDxMjgEeRlOyGGNlgttn0syi4g2rVSukFVqQ6FbeRs5xbnFmS6kaQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-1.0.1.tgz", + "integrity": "sha512-/9ObwNch4Z/NJYfkO4AvqBWku60Ju+c2Ck32toPOLmWe/V6eI9FLn8C1abri+GxDRCkLIqvkaWU1lgZ3nWZIIw==", + "dependencies": { + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-1.0.1.tgz", + "integrity": "sha512-XQM3KvqRLgv7bwAzVkXTITkOmcOINoG9icJiGT8FA0zV35lY5UvyIsg5kHw01xigQS8ufa/33AwG3ZoXip+V5g==", + "dependencies": { + "@smithy/config-resolver": "^1.0.1", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-1.0.1.tgz", + "integrity": "sha512-FPTtMz/t02/rbfq5Pdll/TWUYP+GVFLCQNr+DgifrLzVRU0g8rdRjyFpDh8nPTdkDDusTTo9P1bepAYj68s0eA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-1.0.1.tgz", + "integrity": "sha512-u9akN3Zmbr0vZH4F+2iehG7cFg+3fvDfnvS/hhsXH4UHuhqiQ+ADefibnLzPoz1pooY7rvwaQ/TVHyJmZHdLdQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-1.0.3.tgz", + "integrity": "sha512-gYQnZDD8I2XJFspVwUISyukjPWVikTzKR0IdG8hCWYPTpeULFl1o6yzXlT5SL63TBkuEYl0R1/93cdNtMiNnoA==", + "dependencies": { + "@smithy/service-error-classification": "^1.0.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-1.0.1.tgz", + "integrity": "sha512-4aBCIz35aZAnt2Rbq341KrnUzGhWv2/Zu8HouJqYLvSWCzlrvsNCGlXP4e70Kjzcw8hSuuCNtdUICwQ5qUWLxg==", + "dependencies": { + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-buffer-from": "^1.0.1", + "@smithy/util-hex-encoding": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-1.0.1.tgz", + "integrity": "sha512-IJUrRnXKEIc+PKnU1XzTsIENVR+60jUDPBP3iWX/EvuuT3Xfob47x1FGUe2c3yMXNuU6ax8VDk27hL5LKNoehQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-1.0.1.tgz", + "integrity": "sha512-iX6XHpjh4DFEUIBSKp2tjy3pYnLQMsJ62zYi1BVAC0kobE6p8AVpiZnxsU3ZkgQatAsUaEspFHUZ7CL7oSqaPQ==", + "dependencies": { + "@smithy/util-buffer-from": "^1.0.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", @@ -4033,15 +5031,482 @@ } } }, - "@aws-sdk/abort-controller": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", - "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, + "@aws-sdk/abort-controller": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", + "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", + "requires": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/client-cognito-identity-provider": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.363.0.tgz", + "integrity": "sha512-TkwUy5IJT021CQE3+mnEEnOcoxkNrIv/T0dXiYGivx+xt06PpjbxOY8S1UDa3oZ9zZ2ZR5v6v7AfcA58lwImPA==", + "requires": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.363.0", + "@aws-sdk/credential-provider-node": "3.363.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + }, + "dependencies": { + "@aws-crypto/ie11-detection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", + "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "requires": { + "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/sha256-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", + "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "requires": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/sha256-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", + "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "requires": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", + "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "requires": { + "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "requires": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, + "@aws-sdk/client-sso": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.363.0.tgz", + "integrity": "sha512-PZ+HfKSgS4hlMnJzG+Ev8/mgHd/b/ETlJWPSWjC/f2NwVoBQkBnqHjdyEx7QjF6nksJozcVh5Q+kkYLKc/QwBQ==", + "requires": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/client-sso-oidc": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.363.0.tgz", + "integrity": "sha512-V3Ebiq/zNtDS/O92HUWGBa7MY59RYSsqWd+E0XrXv6VYTA00RlMTbNcseivNgp2UghOgB9a20Nkz6EqAeIN+RQ==", + "requires": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.2", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/protocol-http": "^1.0.1", + "@smithy/smithy-client": "^1.0.3", + "@smithy/types": "^1.0.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.2", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/client-sts": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.363.0.tgz", + "integrity": "sha512-0jj14WvBPJQ8xr72cL0mhlmQ90tF0O0wqXwSbtog6PsC8+KDE6Yf+WsxsumyI8E5O8u3eYijBL+KdqG07F/y/w==", + "requires": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/credential-provider-node": "3.363.0", + "@aws-sdk/middleware-host-header": "3.363.0", + "@aws-sdk/middleware-logger": "3.363.0", + "@aws-sdk/middleware-recursion-detection": "3.363.0", + "@aws-sdk/middleware-sdk-sts": "3.363.0", + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/middleware-user-agent": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@aws-sdk/util-user-agent-browser": "3.363.0", + "@aws-sdk/util-user-agent-node": "3.363.0", + "@smithy/config-resolver": "^1.0.1", + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/hash-node": "^1.0.1", + "@smithy/invalid-dependency": "^1.0.1", + "@smithy/middleware-content-length": "^1.0.1", + "@smithy/middleware-endpoint": "^1.0.1", + "@smithy/middleware-retry": "^1.0.1", + "@smithy/middleware-serde": "^1.0.1", + "@smithy/middleware-stack": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/node-http-handler": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/smithy-client": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-body-length-browser": "^1.0.1", + "@smithy/util-body-length-node": "^1.0.1", + "@smithy/util-defaults-mode-browser": "^1.0.1", + "@smithy/util-defaults-mode-node": "^1.0.1", + "@smithy/util-retry": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-env": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.363.0.tgz", + "integrity": "sha512-VAQ3zITT2Q0acht0HezouYnMFKZ2vIOa20X4zQA3WI0HfaP4D6ga6KaenbDcb/4VFiqfqiRHfdyXHP0ThcDRMA==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-ini": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.363.0.tgz", + "integrity": "sha512-ZYN+INoqyX5FVC3rqUxB6O8nOWkr0gHRRBm1suoOlmuFJ/WSlW/uUGthRBY5x1AQQnBF8cpdlxZzGHd41lFVNw==", + "requires": { + "@aws-sdk/credential-provider-env": "3.363.0", + "@aws-sdk/credential-provider-process": "3.363.0", + "@aws-sdk/credential-provider-sso": "3.363.0", + "@aws-sdk/credential-provider-web-identity": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-node": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.363.0.tgz", + "integrity": "sha512-C1qXFIN2yMxD6pGgug0vR1UhScOki6VqdzuBHzXZAGu7MOjvgHNdscEcb3CpWnITHaPL2ztkiw75T1sZ7oIgQg==", + "requires": { + "@aws-sdk/credential-provider-env": "3.363.0", + "@aws-sdk/credential-provider-ini": "3.363.0", + "@aws-sdk/credential-provider-process": "3.363.0", + "@aws-sdk/credential-provider-sso": "3.363.0", + "@aws-sdk/credential-provider-web-identity": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-process": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.363.0.tgz", + "integrity": "sha512-fOKAINU7Rtj2T8pP13GdCt+u0Ml3gYynp8ki+1jMZIQ+Ju/MdDOqZpKMFKicMn3Z1ttUOgqr+grUdus6z8ceBQ==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-sso": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.363.0.tgz", + "integrity": "sha512-5RUZ5oM0lwZSo3EehT0dXggOjgtxFogpT3cZvoLGtIwrPBvm8jOQPXQUlaqCj10ThF1sYltEyukz/ovtDwYGew==", + "requires": { + "@aws-sdk/client-sso": "3.363.0", + "@aws-sdk/token-providers": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/credential-provider-web-identity": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.363.0.tgz", + "integrity": "sha512-Z6w7fjgy79pAax580wdixbStQw10xfyZ+hOYLcPudoYFKjoNx0NQBejg5SwBzCF/HQL23Ksm9kDfbXDX9fkPhA==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-host-header": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.363.0.tgz", + "integrity": "sha512-FobpclDCf5Y1ueyJDmb9MqguAdPssNMlnqWQpujhYVABq69KHu73fSCWSauFPUrw7YOpV8kG1uagDF0POSxHzA==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-logger": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.363.0.tgz", + "integrity": "sha512-SSGgthScYnFGTOw8EzbkvquqweFmvn7uJihkpFekbtBNGC/jGOGO+8ziHjTQ8t/iI/YKubEwv+LMi0f77HKSEg==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-recursion-detection": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.363.0.tgz", + "integrity": "sha512-MWD/57QgI/N7fG8rtzDTUdSqNpYohQfgj9XCFAoVeI/bU4usrkOrew43L4smJG4XrDxlNT8lSJlDtd64tuiUZA==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-sdk-sts": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.363.0.tgz", + "integrity": "sha512-1yy2Ac50FO8BrODaw5bPWvVrRhaVLqXTFH6iHB+dJLPUkwtY5zLM3Mp+9Ilm7kME+r7oIB1wuO6ZB1Lf4ZszIw==", + "requires": { + "@aws-sdk/middleware-signing": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-signing": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.363.0.tgz", + "integrity": "sha512-/7qia715pt9JKYIPDGu22WmdZxD8cfF/5xB+1kmILg7ZtjO0pPuTaCNJ7xiIuFd7Dn7JXp5lop08anX/GOhNRQ==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/signature-v4": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-middleware": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/middleware-user-agent": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.363.0.tgz", + "integrity": "sha512-ri8YaQvXP6odteVTMfxPqFR26Q0h9ejtqhUDv47P34FaKXedEM4nC6ix6o+5FEYj6l8syGyktftZ5O70NoEhug==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@aws-sdk/util-endpoints": "3.357.0", + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/token-providers": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.363.0.tgz", + "integrity": "sha512-6+0aJ1zugNgsMmhTtW2LBWxOVSaXCUk2q3xyTchSXkNzallYaRiZMRkieW+pKNntnu0g5H1T0zyfCO0tbXwxEA==", + "requires": { + "@aws-sdk/client-sso-oidc": "3.363.0", + "@aws-sdk/types": "3.357.0", + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/types": { + "version": "3.357.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.357.0.tgz", + "integrity": "sha512-/riCRaXg3p71BeWnShrai0y0QTdXcouPSM0Cn1olZbzTf7s71aLEewrc96qFrL70XhY4XvnxMpqQh+r43XIL3g==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@aws-sdk/util-endpoints": { + "version": "3.357.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.357.0.tgz", + "integrity": "sha512-XHKyS5JClT9su9hDif715jpZiWHQF9gKZXER8tW0gOizU3R9cyWc9EsJ2BRhFNhi7nt/JF/CLUEc5qDx3ETbUw==", + "requires": { + "@aws-sdk/types": "3.357.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/util-user-agent-browser": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.363.0.tgz", + "integrity": "sha512-fk9ymBUIYbxiGm99Cn+kAAXmvMCWTf/cHAcB79oCXV4ELXdPa9lN5xQhZRFNxLUeXG4OAMEuCAUUuZEj8Fnc1Q==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/types": "^1.1.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "@aws-sdk/util-user-agent-node": { + "version": "3.363.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.363.0.tgz", + "integrity": "sha512-Fli/dvgGA9hdnQUrYb1//wNSFlK2jAfdJcfNXA6SeBYzSeH5pVGYF4kXF0FCdnMA3Fef+Zn1zAP/hw9v8VJHWQ==", + "requires": { + "@aws-sdk/types": "3.357.0", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "fast-xml-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", + "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "requires": { + "strnum": "^1.0.5" + } + } + } + }, "@aws-sdk/client-dynamodb": { "version": "3.216.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.216.0.tgz", @@ -6398,20 +7863,387 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@smithy/abort-controller": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-1.0.1.tgz", + "integrity": "sha512-An6irzp9NCji2JtJHhrEFlDbxLwHd6c6Y9fq3ZeomyUR8BIXlGXVTxsemUSZVVgOq3166iYbYs/CrPAmgRSFLw==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/config-resolver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-1.0.1.tgz", + "integrity": "sha512-quj0xUiEVG/UHfY82EtthR/+S5/17p3IxXArC3NFSNqryMobWbG9oWgJy2s2cgUSVZLzxevjKKvxrilK7JEDaA==", + "requires": { + "@smithy/types": "^1.1.0", + "@smithy/util-config-provider": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/credential-provider-imds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-1.0.1.tgz", + "integrity": "sha512-hkRJoxVCh4CEt1zYOBElE+G/MV6lyx3g68hSJpesM4pwMT/bzEVo5E5XzXY+6dVq8yszeatWKbFuqCCBQte8tg==", + "requires": { + "@smithy/node-config-provider": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/eventstream-codec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-1.0.1.tgz", + "integrity": "sha512-cpcTXQEOEs2wEvIyxW/iTHJ2m0RVqoEOTjjWEXD6SY8Gcs3FCFP6E8MXadC098tdH5ctMIUXc8POXyMpxzGnjw==", + "requires": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^1.1.0", + "@smithy/util-hex-encoding": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/fetch-http-handler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-1.0.1.tgz", + "integrity": "sha512-/e2A8eOMk4FVZBQ0o6uF/ttLtFZcmsK5MIwDu1UE3crM4pCAIP19Ul8U9rdLlHhIu81X4AcJmSw55RDSpVRL/w==", + "requires": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/querystring-builder": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-base64": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/hash-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-1.0.1.tgz", + "integrity": "sha512-eCz08BySBcOjVObjbRAS/XMKUGY4ujnuS+GoWeEpzpCSKDnO8/YQ0rStRt4C0llRmhApizYc1tK9DiJwfvXcBg==", + "requires": { + "@smithy/types": "^1.1.0", + "@smithy/util-buffer-from": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/invalid-dependency": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-1.0.1.tgz", + "integrity": "sha512-kib63GFlAzRn/wf8M0cRWrZA1cyOy5IvpTkLavCY782DPFMP0EaEeD6VrlNIOvD6ncf7uCJ68HqckhwK1qLT3g==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/is-array-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-1.0.1.tgz", + "integrity": "sha512-fHSTW70gANnzPYWNDcWkPXpp+QMbHhKozbQm/+Denkhp4gwSiPuAovWZRpJa9sXO+Q4dOnNzYN2max1vTCEroA==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/middleware-content-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-1.0.1.tgz", + "integrity": "sha512-vWWigayk5i2cFp9xPX5vdzHyK+P0t/xZ3Ovp4Ss+c8JQ1Hlq2kpJZVWtTKsmdfND5rVo5lu0kD5wgAMUCcmuhw==", + "requires": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/middleware-endpoint": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-1.0.2.tgz", + "integrity": "sha512-F3CyXgjtDI4quGFkDmVNytt6KMwlzzeMxtopk6Edue4uKdKcMC1vUmoRS5xTbFzKDDp4XwpnEV7FshPaL3eCPw==", + "requires": { + "@smithy/middleware-serde": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/url-parser": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/middleware-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-1.0.3.tgz", + "integrity": "sha512-ZRsjG8adtxQ456FULPqPFmWtrW44Fq8IgdQvQB+rC2RSho3OUzS+TiEIwb5Zs6rf2IoewITKtfdtsUZcxXO0ng==", + "requires": { + "@smithy/protocol-http": "^1.1.0", + "@smithy/service-error-classification": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/util-middleware": "^1.0.1", + "@smithy/util-retry": "^1.0.3", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, + "@smithy/middleware-serde": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-1.0.1.tgz", + "integrity": "sha512-bn5lWk8UUeXFCQfkrNErz5SbeNd+2hgYegHMLsOLPt4URDIsyREar6wMsdsR+8UCdgR5s8udG3Zalgc7puizIQ==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/middleware-stack": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-1.0.1.tgz", + "integrity": "sha512-T6+gsAO1JYamOJqmORCrByDeQ/NB+ggjHb33UDOgdX4xIjXz/FB/3UqHgQu6PL1cSFrK+i4oteDIwqARDs/Szw==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/node-config-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-1.0.1.tgz", + "integrity": "sha512-FRxifH/J2SgOaVLihIqBFuGhiHR/NfzbZYp5nYO7BGgT/gc/f9nAuuRJcEy/hwO3aI6ThyG5apH4tGec6A2sCw==", + "requires": { + "@smithy/property-provider": "^1.0.1", + "@smithy/shared-ini-file-loader": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/node-http-handler": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-1.0.2.tgz", + "integrity": "sha512-PzPrGRSt3kNuruLCeR4ffJp57ZLVnIukMXVL3Ppr65ZoxiE+HBsOVAa/Z/T+4HzjCM6RaXnnmB8YKfsDjlb0iA==", + "requires": { + "@smithy/abort-controller": "^1.0.1", + "@smithy/protocol-http": "^1.1.0", + "@smithy/querystring-builder": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/property-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-1.0.1.tgz", + "integrity": "sha512-3EG/61Ls1MrgEaafpltXBJHSqFPqmTzEX7QKO7lOEHuYGmGYzZ08t1SsTgd1vM74z0IihoZyGPynZ7WmXKvTeg==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, "@smithy/protocol-http": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.1.0.tgz", + "integrity": "sha512-H5y/kZOqfJSqRkwtcAoVbqONmhdXwSgYNJ1Glk5Ry8qlhVVy5qUzD9EklaCH8/XLnoCsLO/F/Giee8MIvaBRkg==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/querystring-builder": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", - "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-1.0.1.tgz", + "integrity": "sha512-J5Tzkw1PMtu01h6wl+tlN5vsyROmS6/z5lEfNlLo/L4ELHeVkQ4Q0PEIjDddPLfjVLCm8biQTESE5GCMixSRNQ==", "requires": { - "@smithy/types": "^1.0.0", + "@smithy/types": "^1.1.0", + "@smithy/util-uri-escape": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/querystring-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-1.0.1.tgz", + "integrity": "sha512-zauxdMc3cwxoLitI5DZqH7xN6Fk0mwRxrUMAETbav2j6Se2U0UGak/55rZcDg2yGzOURaLYi5iOm1gHr98P+Bw==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/service-error-classification": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-1.0.2.tgz", + "integrity": "sha512-Q5CCuzYL5FGo6Rr/O+lZxXHm2hrRgbmMn8MgyjqZUWZg20COg20DuNtIbho2iht6CoB7jOpmpBqhWizLlzUZgg==" + }, + "@smithy/shared-ini-file-loader": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-1.0.1.tgz", + "integrity": "sha512-EztziuIPoNronENGqh+MWVKJErA4rJpaPzJCPukzBeEoG2USka0/q4B5Mr/1zszOnrb49fPNh4u3u5LfiH7QzA==", + "requires": { + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/signature-v4": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-1.0.1.tgz", + "integrity": "sha512-2D69je14ou1vBTnAQeysSK4QVMm0j3WHS3MDg/DnHnFFcXRCzVl/xAARO7POD8+fpi4tMFPs8Z4hzo1Zw40L0Q==", + "requires": { + "@smithy/eventstream-codec": "^1.0.1", + "@smithy/is-array-buffer": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-hex-encoding": "^1.0.1", + "@smithy/util-middleware": "^1.0.1", + "@smithy/util-uri-escape": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/smithy-client": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-1.0.3.tgz", + "integrity": "sha512-Wh1mNP/1yUZK0uYkgCQ6NMxpBT3Fmc45TMdUfOlH1xD2zGYL7U4yDHFOhEZdi/suyjaelFobXB2p9pPIw6LjRQ==", + "requires": { + "@smithy/middleware-stack": "^1.0.1", + "@smithy/types": "^1.1.0", + "@smithy/util-stream": "^1.0.1", "tslib": "^2.5.0" } }, "@smithy/types": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", - "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.1.0.tgz", + "integrity": "sha512-KzmvisMmuwD2jZXuC9e65JrgsZM97y5NpDU7g347oB+Q+xQLU6hQZ5zFNNbEfwwOJHoOvEVTna+dk1h/lW7alw==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/url-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-1.0.1.tgz", + "integrity": "sha512-33vWEtE6HzmwjEcEb4I58XMLRAchwPS93YhfDyXAXr1jwDCzfXmMayQwwpyW847rpWj0XJimxqia8q0z+k/ybw==", + "requires": { + "@smithy/querystring-parser": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/util-base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-1.0.1.tgz", + "integrity": "sha512-rJcpRi/yUi6TyCEkjdTH86/ExBuKlfctEXhG9/4gMJ3/cnPcHJJnr0mQ9evSEO+3DbpT/Nxq90bcTBdTIAmCig==", + "requires": { + "@smithy/util-buffer-from": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/util-body-length-browser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-1.0.1.tgz", + "integrity": "sha512-Pdp744fmF7E1NWoSb7256Anhm8eYoCubvosdMwXzOnHuPRVbDa15pKUz2027K3+jrfGpXo1r+MnDerajME1Osw==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-body-length-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-1.0.1.tgz", + "integrity": "sha512-4PIHjDFwG07SNensAiVq/CJmubEVuwclWSYOTNtzBNTvxOeGLznvygkGYgPzS3erByT8C4S9JvnLYgtrsVV3nQ==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-buffer-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-1.0.1.tgz", + "integrity": "sha512-363N7Wq0ceUgE5lLe6kaR6GlJs2/m4r9V6bRMfIszb6P1FZbbRRM2FQYUWWPFSsRymm9mJL18b3fjiVsIvhDGg==", + "requires": { + "@smithy/is-array-buffer": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/util-config-provider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-1.0.1.tgz", + "integrity": "sha512-4Qy38Oy5/q43MpTwCLV1P+7NeaOp4W2etQDxMjgEeRlOyGGNlgttn0syi4g2rVSukFVqQ6FbeRs5xbnFmS6kaQ==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-defaults-mode-browser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-1.0.1.tgz", + "integrity": "sha512-/9ObwNch4Z/NJYfkO4AvqBWku60Ju+c2Ck32toPOLmWe/V6eI9FLn8C1abri+GxDRCkLIqvkaWU1lgZ3nWZIIw==", + "requires": { + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "@smithy/util-defaults-mode-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-1.0.1.tgz", + "integrity": "sha512-XQM3KvqRLgv7bwAzVkXTITkOmcOINoG9icJiGT8FA0zV35lY5UvyIsg5kHw01xigQS8ufa/33AwG3ZoXip+V5g==", + "requires": { + "@smithy/config-resolver": "^1.0.1", + "@smithy/credential-provider-imds": "^1.0.1", + "@smithy/node-config-provider": "^1.0.1", + "@smithy/property-provider": "^1.0.1", + "@smithy/types": "^1.1.0", + "tslib": "^2.5.0" + } + }, + "@smithy/util-hex-encoding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-1.0.1.tgz", + "integrity": "sha512-FPTtMz/t02/rbfq5Pdll/TWUYP+GVFLCQNr+DgifrLzVRU0g8rdRjyFpDh8nPTdkDDusTTo9P1bepAYj68s0eA==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-middleware": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-1.0.1.tgz", + "integrity": "sha512-u9akN3Zmbr0vZH4F+2iehG7cFg+3fvDfnvS/hhsXH4UHuhqiQ+ADefibnLzPoz1pooY7rvwaQ/TVHyJmZHdLdQ==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-1.0.3.tgz", + "integrity": "sha512-gYQnZDD8I2XJFspVwUISyukjPWVikTzKR0IdG8hCWYPTpeULFl1o6yzXlT5SL63TBkuEYl0R1/93cdNtMiNnoA==", + "requires": { + "@smithy/service-error-classification": "^1.0.2", + "tslib": "^2.5.0" + } + }, + "@smithy/util-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-1.0.1.tgz", + "integrity": "sha512-4aBCIz35aZAnt2Rbq341KrnUzGhWv2/Zu8HouJqYLvSWCzlrvsNCGlXP4e70Kjzcw8hSuuCNtdUICwQ5qUWLxg==", + "requires": { + "@smithy/fetch-http-handler": "^1.0.1", + "@smithy/node-http-handler": "^1.0.2", + "@smithy/types": "^1.1.0", + "@smithy/util-base64": "^1.0.1", + "@smithy/util-buffer-from": "^1.0.1", + "@smithy/util-hex-encoding": "^1.0.1", + "@smithy/util-utf8": "^1.0.1", + "tslib": "^2.5.0" + } + }, + "@smithy/util-uri-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-1.0.1.tgz", + "integrity": "sha512-IJUrRnXKEIc+PKnU1XzTsIENVR+60jUDPBP3iWX/EvuuT3Xfob47x1FGUe2c3yMXNuU6ax8VDk27hL5LKNoehQ==", + "requires": { + "tslib": "^2.5.0" + } + }, + "@smithy/util-utf8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-1.0.1.tgz", + "integrity": "sha512-iX6XHpjh4DFEUIBSKp2tjy3pYnLQMsJ62zYi1BVAC0kobE6p8AVpiZnxsU3ZkgQatAsUaEspFHUZ7CL7oSqaPQ==", "requires": { + "@smithy/util-buffer-from": "^1.0.1", "tslib": "^2.5.0" } }, diff --git a/cdk/package.json b/cdk/package.json index 78e940da..4e49c221 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -21,6 +21,7 @@ "uuid": "^9.0.0" }, "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.363.0", "@aws-sdk/client-ivs": "^3.354.0", "aws-cdk-lib": "2.45.0", "cdk-ecr-deployment": "^2.5.6", diff --git a/screenshots/architecture/poll.png b/screenshots/architecture/poll.png new file mode 100644 index 00000000..b74c4943 Binary files /dev/null and b/screenshots/architecture/poll.png differ diff --git a/screenshots/features/action-poll.png b/screenshots/features/action-poll.png new file mode 100644 index 00000000..336f5af9 Binary files /dev/null and b/screenshots/features/action-poll.png differ diff --git a/web-ui/.prettierignore b/web-ui/.prettierignore index 607d477b..946df7ab 100644 --- a/web-ui/.prettierignore +++ b/web-ui/.prettierignore @@ -1,4 +1,4 @@ /node_modules/** /build/** /e2e/playwright-report/** -/e2e/build/** \ No newline at end of file +/e2e/build/** diff --git a/web-ui/e2e/Dockerfile b/web-ui/e2e/Dockerfile index 7509008d..22d8d4dd 100644 --- a/web-ui/e2e/Dockerfile +++ b/web-ui/e2e/Dockerfile @@ -1,5 +1,5 @@ FROM node:16-alpine -FROM mcr.microsoft.com/playwright:v1.35.0-jammy +FROM mcr.microsoft.com/playwright:v1.36.0-jammy # Set the work directory for the application diff --git a/web-ui/e2e/__mocks__/encodedStreamManagerData.json b/web-ui/e2e/__mocks__/encodedStreamManagerData.json index b7be6329..7c127a4d 100644 --- a/web-ui/e2e/__mocks__/encodedStreamManagerData.json +++ b/web-ui/e2e/__mocks__/encodedStreamManagerData.json @@ -1,5 +1,5 @@ { - "quiz": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIA6gBZwQAE6YHELMHFHAAO6CHAA2HTADMOAYTjY4AEzgB+EABoQSsAHcYUPPgDaIAGpKUmEgDcj2kAHkI4/XCcAVTFBwRMEABdHRsoWDQkXEMoAElsFRgADwIARh0VEih2egIAJgBWAF8dYT9MtCoQDAgJGEYnMvQUevwQJ3QEOABzGABVKAkGjNoUKHRhDBwGkpBsTAwWqpq64ZAEWjAe1vaMrJzp/FTi0Jg6gCNsqYZCEEyr3KOABlnEOHIcAH0yzAqIKrAZxgaBgKgACuUSGh4okUvgnjpAXUQSoAMq+CCKCAwbq+ACejAASkC7NZ6joANYwPH6XwqNY/P7yFiYZqtYCzRlQiDOSb0EymEIgLloMHbAByJAQ50cRyK8qAA===", - "product": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIIANCHLgO4xR74DaTViyYBdVikxRYaJJ24BJbABMYADwIAGVkpJQ4GHAQCMAVgC+rAA5RMOtFRAYIAGxiMAytngBrbnitQ6Chu+CAAJABMJhrC6AhwAOYwAKpQzowAFhAQlmD4APT5cJboAHTOkjAIpWCWcMH5cYkw+WAZmLQA/BwAvEYaEQAsAGQZfQODwipgKIGWBgyhAJqYugAEkLCI3ADkYGsAZnAAbpLoEDAbXnC+PCAWINiYGMEOTq6Mwgi0YM2f2rp9PRjOZxDBXAAjPQLBw6aHA/D9B6IODkHAAfWsthI9kI4HBMDQMCUAAUbHYIIoVOp8Fp8a4iUp3JIIABhfQwBKSACejAASgTjuxXqxfNyOJIlP8QFiKaz2kEQsAHrKcRAAPLzei8PhiGXktUk5oAORICAh3GMZmtQA==", - "notice": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIIANCHLgO4xR74DaTViyYBdVikxRYaJJ24BJbABMYADwIAGVkpJQ4GHAQCMAVgC+rAA5RMOtFRAYIAGxiNh19Cjf5BIdAhwAOYwAKpQzu7atChQ6JYGDL4gFiDYmBjeDk6ujABMGhoA1gAEYCQARnisCLRgwT4gAJpwAJ4lBcVllWDMJRAAFuxFYCVwzs4AhMI6eokEeebiMK4Vc/QOs/ob+EYaqYhw5DgA+ta2JPaE4CswaDBKAAo2dhCKKur4Wjeu90oAypIIABhfQwIKSVqMABKtwAbuwsqwijBWhxJEooiBzq9gQNMF4fMBUjjLhAAPIJei8PhibEvMmPBoAORICAq3GMZm5QA==" + "quiz": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIA6gBZwQAE6YHELMHFHAAO6CHAA2HTADMOAYTjY4AEzgB+EABoQSsAHcYUPPgDaIAGpKUmEgDcj2kAHkI4/XCeKJAczhQATxAAXR0bKFg0JFxDKABJbBUYAA8CAEYdFRIodnoCACYAVgBfHWEoTCy0KhAMCAkYRidy9BRG/BAndAQ4HxgAVSgJJszaFCh0YQwcJtKQbEwMNpq6hpGQBFowXvbOzOzcmfw0krCYBoAjHOmGQhAs67zjgAY5xDhyHAB9csqSaruYHOMDQMBUAAUKlUIAkkql8M8dECGqCVABlTBQCCKCAwHyYoIdABKwLs1kaOgA1jAAvpMSp1r9ofIWJhWu1gHMmf8IM4pvQTKZQiBuWhwTsAHIkBAXRzHLmYCTDO6kWg3dZ6WKCvadYUPQ63E7FY1AA=", + "product": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIIANCHLgO4xR74DaTViyYBdVikxRYaJJ24BJbABMYADwIAGVkpJQ4GHAQCMAVgC+rAA5RMOtFRAYIAGxiMAytngBrbnitQ6Chu+CAAJABMAOwawugIcADmMACqUM6MABYQEJZg+AD0BXCW6AB0zpIwCGVglnDBBfFJMAVgmZi0APwcALxGGhEALABkmf2DQ8IqYCiBlgYMoQCamLoABJCwiNwA5GDrAGZwAG6S6BAwm15wvjwgFiDYmBjBDk6ujMIItGAtX9pdPp6MZzOIYK4AEZ6RYOHQwkH4AaPRBwcg4AD61lsJHshHAEJgaBgSgACjY7BBFCp1PgtATXMSlO5JBAAML6GCJSQAT0YACVCSd2G9WL4eRxJEoASBsZS2R0giFgI85biIAB5Bb0Xh8MSyinq0ktAByJAQkO4xlVmGcGXxpFosNCwnYYC4934glEgIRhiR5jMZiAA===", + "notice": "N4IgzgLgTgpghgWwIIGMIEsD2A7MIBcoAjgK7oBeBxJMkW2BIIANCHLgO4xR74DaTViyYBdVikxRYaJJ24BJbABMYADwIAGVkpJQ4GHAQCMAVgC+rAA5RMOtFRAYIAGxiNh19Cjf5BIdAhwAOYwAKpQzu7atChQ6JYGDL4gFiDYmBjeDk6ujABMGhoA1gAEYCQARnisCLRgwT4gAJpwAJ4lAFYkkCWwcCgAFjBKJQXFZZVgzCUQA+xFYCVwzs4AhMI6eokEeebiMK4VW/QOm/on+EYaqYhw5DgA+ta2JPaE4AcwaMMACjZ2EEUKnU+C0H1c3yUAGVJBAAML6GBBSStRgAJU+ADd2FlWEUYK0OJIlFEQM8AXCBpgvD5gKlya8IAB5BL0Xh8MRk/6Mn4NAByJAQFW4xnpmBWDlItG2yVY7DAXB4BAEwhAnLOMtMZm1QA==" } diff --git a/web-ui/e2e/__mocks__/webSocketServer.js b/web-ui/e2e/__mocks__/webSocketServer.js index eccb8ce5..e8c9ac51 100644 --- a/web-ui/e2e/__mocks__/webSocketServer.js +++ b/web-ui/e2e/__mocks__/webSocketServer.js @@ -38,7 +38,7 @@ wss.on('connection', (ws) => { Type: 'MESSAGE', Id: uuidv4(), RequestId, - Attributes: null, + Attributes: { eventType: 'SEND_MESSAGE' }, Content, SendTime, Sender: userData diff --git a/web-ui/e2e/__mocks__/webSocketTokens.js b/web-ui/e2e/__mocks__/webSocketTokens.js index 9e4b9732..94389d66 100644 --- a/web-ui/e2e/__mocks__/webSocketTokens.js +++ b/web-ui/e2e/__mocks__/webSocketTokens.js @@ -6,7 +6,7 @@ module.exports = { channelAssetUrls: '{}', color: 'salmon', displayName: 'testUser', - channelArn: 'channel/channelId' + channelArn: 'channel/trackingid' } }, john: { diff --git a/web-ui/e2e/__tests__/channel.spec.js-snapshots/ChannelPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png b/web-ui/e2e/__tests__/channel.spec.js-snapshots/ChannelPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png index dde25ad3..2dca6291 100644 Binary files a/web-ui/e2e/__tests__/channel.spec.js-snapshots/ChannelPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png and b/web-ui/e2e/__tests__/channel.spec.js-snapshots/ChannelPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png differ diff --git a/web-ui/e2e/__tests__/directory.spec.js-snapshots/DirectoryPage-follower-carousel-navigate-to-next-page-firefox-linux.png b/web-ui/e2e/__tests__/directory.spec.js-snapshots/DirectoryPage-follower-carousel-navigate-to-next-page-firefox-linux.png index 5ab19fb8..674e372e 100644 Binary files a/web-ui/e2e/__tests__/directory.spec.js-snapshots/DirectoryPage-follower-carousel-navigate-to-next-page-firefox-linux.png and b/web-ui/e2e/__tests__/directory.spec.js-snapshots/DirectoryPage-follower-carousel-navigate-to-next-page-firefox-linux.png differ diff --git a/web-ui/e2e/__tests__/register.spec.js-snapshots/RegisterPage-new-user-registration-filled-form-firefox-linux.png b/web-ui/e2e/__tests__/register.spec.js-snapshots/RegisterPage-new-user-registration-filled-form-firefox-linux.png index 5b2cb78b..5d5903c6 100644 Binary files a/web-ui/e2e/__tests__/register.spec.js-snapshots/RegisterPage-new-user-registration-filled-form-firefox-linux.png and b/web-ui/e2e/__tests__/register.spec.js-snapshots/RegisterPage-new-user-registration-filled-form-firefox-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Chrome-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Chrome-linux.png new file mode 100644 index 00000000..d68495b0 Binary files /dev/null and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Chrome-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Safari-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Safari-linux.png new file mode 100644 index 00000000..30676036 Binary files /dev/null and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-Mobile-Safari-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-chromium-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-chromium-linux.png new file mode 100644 index 00000000..98fcab19 Binary files /dev/null and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-chromium-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-firefox-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-firefox-linux.png new file mode 100644 index 00000000..7cc8dcec Binary files /dev/null and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-firefox-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-webkit-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-webkit-linux.png new file mode 100644 index 00000000..e6cc141a Binary files /dev/null and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-navigate-to-stream-manager-webkit-linux.png differ diff --git a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-reset-stream-key-success-Mobile-Safari-linux.png b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-reset-stream-key-success-Mobile-Safari-linux.png index f6c30cac..d60f13ce 100644 Binary files a/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-reset-stream-key-success-Mobile-Safari-linux.png and b/web-ui/e2e/__tests__/settings.spec.js-snapshots/SettingsPage-reset-stream-key-success-Mobile-Safari-linux.png differ diff --git a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png index b5e41d37..3d59516a 100644 Binary files a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png and b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-chat-viewer-sends-message-moderator-bans-viewer-Mobile-Safari-linux.png differ diff --git a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-chromium-linux.png b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-chromium-linux.png index 5b8a5088..e8999d93 100644 Binary files a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-chromium-linux.png and b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-chromium-linux.png differ diff --git a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-firefox-linux.png b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-firefox-linux.png index 2dd32413..84c771aa 100644 Binary files a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-firefox-linux.png and b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-firefox-linux.png differ diff --git a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-webkit-linux.png b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-webkit-linux.png index 24b54b78..59e40e4b 100644 Binary files a/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-webkit-linux.png and b/web-ui/e2e/__tests__/streamManager.spec.js-snapshots/StreamManagerPage-product-action-modal-closed-webkit-linux.png differ diff --git a/web-ui/e2e/models/StreamManager.js b/web-ui/e2e/models/StreamManager.js index 88743106..8d98b7fc 100644 --- a/web-ui/e2e/models/StreamManager.js +++ b/web-ui/e2e/models/StreamManager.js @@ -149,7 +149,7 @@ class StreamManagerPageModel extends BasePageModel { */ completeQuizForm = async (isPreFilled = false) => { const question = 'What is the capital of Canada?'; - const answers = ['Vancouver', 'Ottawa', 'Toronto']; + const answers = ['Vancouver', 'Ottawa', 'Calgary']; await this.openStreamActionModal('quiz'); @@ -169,14 +169,14 @@ class StreamManagerPageModel extends BasePageModel { // Select the correct answer const correctAnswerRadioBtnLoc = this.page.getByTestId( - `streamManagerActionFormAnswers-${answers[1]}-radio-button` + `streamManagerActionQuizFormAnswers-${answers[1]}-radio-button` ); if (!isPreFilled) await correctAnswerRadioBtnLoc.click(); expect( await this.page .getByTestId( - `streamManagerActionFormAnswers-${answers[0]}-radio-button` + `streamManagerActionQuizFormAnswers-${answers[0]}-radio-button` ) .isChecked() ).toBeFalsy(); @@ -184,7 +184,7 @@ class StreamManagerPageModel extends BasePageModel { expect( await this.page .getByTestId( - `streamManagerActionFormAnswers-${answers[2]}-radio-button` + `streamManagerActionQuizFormAnswers-${answers[2]}-radio-button` ) .isChecked() ).toBeFalsy(); @@ -199,7 +199,7 @@ class StreamManagerPageModel extends BasePageModel { */ completeProductForm = async (isPreFilled = false) => { const title = 'Sneakers'; - const price = '$250'; + const price = '$270'; const imageUrl = 'https://api.lorem.space/image/shoes?w=1024&h=1024'; const description = "Your streamer's favorite sneakers"; @@ -240,7 +240,7 @@ class StreamManagerPageModel extends BasePageModel { */ completeNoticeForm = async (isPreFilled = false) => { const title = '200k subs'; - const message = 'Yay 200k subs, thanks all!'; + const message = 'Yay just reached 200k subs, thanks all!'; await this.openStreamActionModal('notice'); diff --git a/web-ui/e2e/package-lock.json b/web-ui/e2e/package-lock.json index 60f244ee..cf1acec3 100644 --- a/web-ui/e2e/package-lock.json +++ b/web-ui/e2e/package-lock.json @@ -8,7 +8,7 @@ "name": "e2e", "version": "0.1.0", "devDependencies": { - "@playwright/test": "v1.35.0", + "@playwright/test": "v1.36.0", "amazon-cognito-identity-js": "^5.2.12", "dotenv-cli": "^6.0.0", "jsonwebtoken": "^8.5.1", @@ -21,13 +21,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.0.tgz", - "integrity": "sha512-6qXdd5edCBynOwsz1YcNfgX8tNWeuS9fxy5o59D0rvHXxRtjXRebB4gE4vFVfEMXl/z8zTnAzfOs7aQDEs8G4Q==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.0.tgz", + "integrity": "sha512-yN+fvMYtiyLFDCQos+lWzoX4XW3DNuaxjBu68G0lkgLgC6BP+m/iTxJQoSicz/x2G5EsrqlZTqTIP9sTgLQerg==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.35.0" + "playwright-core": "1.36.0" }, "bin": { "playwright": "cli.js" @@ -40,9 +40,9 @@ } }, "node_modules/@types/node": { - "version": "20.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.0.tgz", - "integrity": "sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", + "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==", "dev": true }, "node_modules/@zeit/schemas": { @@ -490,9 +490,9 @@ } }, "node_modules/dotenv": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.1.4.tgz", - "integrity": "sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "dev": true, "engines": { "node": ">=12" @@ -912,9 +912,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", - "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", "dev": true, "dependencies": { "whatwg-url": "^5.0.0" @@ -989,9 +989,9 @@ "dev": true }, "node_modules/playwright-core": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.0.tgz", - "integrity": "sha512-muMXyPmIx/2DPrCHOD1H1ePT01o7OdKxKj2ebmCAYvqhUy+Y1bpal7B0rdoxros7YrXI294JT/DWw2LqyiqTPA==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.0.tgz", + "integrity": "sha512-7RTr8P6YJPAqB+8j5ATGHqD6LvLLM39sYVNsslh78g8QeLcBs5750c6+msjrHUwwGt+kEbczBj1XB22WMwn+WA==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -1082,9 +1082,9 @@ ] }, "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "bin": { "semver": "bin/semver" @@ -1398,20 +1398,20 @@ }, "dependencies": { "@playwright/test": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.0.tgz", - "integrity": "sha512-6qXdd5edCBynOwsz1YcNfgX8tNWeuS9fxy5o59D0rvHXxRtjXRebB4gE4vFVfEMXl/z8zTnAzfOs7aQDEs8G4Q==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.0.tgz", + "integrity": "sha512-yN+fvMYtiyLFDCQos+lWzoX4XW3DNuaxjBu68G0lkgLgC6BP+m/iTxJQoSicz/x2G5EsrqlZTqTIP9sTgLQerg==", "dev": true, "requires": { "@types/node": "*", "fsevents": "2.3.2", - "playwright-core": "1.35.0" + "playwright-core": "1.36.0" } }, "@types/node": { - "version": "20.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.0.tgz", - "integrity": "sha512-cumHmIAf6On83X7yP+LrsEyUOf/YlociZelmpRYaGFydoaPdxdt80MAbu6vWerQT2COCp2nPvHdsbD7tHn/YlQ==", + "version": "20.4.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", + "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==", "dev": true }, "@zeit/schemas": { @@ -1742,9 +1742,9 @@ "dev": true }, "dotenv": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.1.4.tgz", - "integrity": "sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", "dev": true }, "dotenv-cli": { @@ -2064,9 +2064,9 @@ "dev": true }, "node-fetch": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", - "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", "dev": true, "requires": { "whatwg-url": "^5.0.0" @@ -2115,9 +2115,9 @@ "dev": true }, "playwright-core": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.0.tgz", - "integrity": "sha512-muMXyPmIx/2DPrCHOD1H1ePT01o7OdKxKj2ebmCAYvqhUy+Y1bpal7B0rdoxros7YrXI294JT/DWw2LqyiqTPA==", + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.0.tgz", + "integrity": "sha512-7RTr8P6YJPAqB+8j5ATGHqD6LvLLM39sYVNsslh78g8QeLcBs5750c6+msjrHUwwGt+kEbczBj1XB22WMwn+WA==", "dev": true }, "punycode": { @@ -2176,9 +2176,9 @@ "dev": true }, "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true }, "serve": { diff --git a/web-ui/e2e/package.json b/web-ui/e2e/package.json index 9f4d94b4..d03236f6 100644 --- a/web-ui/e2e/package.json +++ b/web-ui/e2e/package.json @@ -14,7 +14,7 @@ "test:e2e:ci": "npx playwright test" }, "devDependencies": { - "@playwright/test": "v1.35.0", + "@playwright/test": "v1.36.0", "amazon-cognito-identity-js": "^5.2.12", "dotenv-cli": "^6.0.0", "jsonwebtoken": "^8.5.1", diff --git a/web-ui/public/index.html b/web-ui/public/index.html index 52ebc4ed..563f5aad 100644 --- a/web-ui/public/index.html +++ b/web-ui/public/index.html @@ -27,9 +27,9 @@ rel="stylesheet" /> - + diff --git a/web-ui/src/App.jsx b/web-ui/src/App.jsx index fb7f12d1..8584905a 100644 --- a/web-ui/src/App.jsx +++ b/web-ui/src/App.jsx @@ -9,7 +9,6 @@ import { MotionConfig } from 'framer-motion'; // Context Providers import { Provider as ChannelProvider } from './contexts/Channel'; -import { Provider as ChatMessagesProvider } from './contexts/ChatMessages'; import { Provider as LastFocusedElementProvider } from './contexts/LastFocusedElement'; import { Provider as ModalProvider } from './contexts/Modal'; import { Provider as NotificationProvider } from './contexts/Notification'; @@ -77,9 +76,7 @@ const router = createBrowserRouter( element={ - - - + } diff --git a/web-ui/src/assets/icons/check-circle.svg b/web-ui/src/assets/icons/check-circle.svg new file mode 100644 index 00000000..4f8176db --- /dev/null +++ b/web-ui/src/assets/icons/check-circle.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web-ui/src/assets/icons/index.js b/web-ui/src/assets/icons/index.js index a81972b6..8a109323 100644 --- a/web-ui/src/assets/icons/index.js +++ b/web-ui/src/assets/icons/index.js @@ -8,6 +8,7 @@ export { ReactComponent as Celebration } from './celebration.svg'; export { ReactComponent as ChatClosed } from './chat-closed.svg'; export { ReactComponent as ChatOpen } from './chat-open.svg'; export { ReactComponent as Check } from './check.svg'; +export { ReactComponent as CheckCircle } from './check-circle.svg'; export { ReactComponent as Checkmark } from './checkmark-round.svg'; export { ReactComponent as ChevronDown } from './chevron-down.svg'; export { ReactComponent as ChevronLeft } from './chevron-left.svg'; @@ -44,6 +45,7 @@ export { ReactComponent as Pause } from './pause.svg'; export { ReactComponent as PersonAdd } from './person-add.svg'; export { ReactComponent as PersonOff } from './person-off.svg'; export { ReactComponent as Play } from './play.svg'; +export { ReactComponent as Poll } from './poll.svg'; export { ReactComponent as Search } from './search.svg'; export { ReactComponent as ScreenShare } from './screenshare.svg'; export { ReactComponent as ScreenShareOff } from './screenshare-off.svg'; diff --git a/web-ui/src/assets/icons/poll.svg b/web-ui/src/assets/icons/poll.svg new file mode 100644 index 00000000..f98d79ab --- /dev/null +++ b/web-ui/src/assets/icons/poll.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web-ui/src/constants.js b/web-ui/src/constants.js index 22c24524..9a695021 100644 --- a/web-ui/src/constants.js +++ b/web-ui/src/constants.js @@ -26,6 +26,14 @@ export const CHANNEL_DATA_REFRESH_INTERVAL = 5000; // 5 seconds /** * CHAT */ +export const CHAT_MESSAGE_EVENT_TYPES = { + SEND_MESSAGE: 'SEND_MESSAGE', + START_POLL: 'START_POLL', + END_POLL: 'END_POLL', + SUBMIT_VOTE: 'SUBMIT_VOTE', + SEND_VOTE_STATS: 'SEND_VOTE_STATS', + HEART_BEAT: 'HEART_BEAT' +}; export const BANNED_USERNAME_CHANNEL_ID_SEPARATOR = 'channel/'; export const MAX_RECONNECT_ATTEMPTS = 7; @@ -140,6 +148,21 @@ export const MODERATOR_PILL_TIMEOUT = 5000; // ms export const COMPOSER_MAX_CHARACTER_LENGTH = 500; export const COMPOSER_RATE_LIMIT_BLOCK_TIME_MS = 2000; // 2 seconds +/** + * STREAM MANAGER POLL ACTION + */ + +export const PROFILE_COLORS_WITH_WHITE_TEXT = ['green', 'blue']; +export const NUM_MILLISECONDS_TO_BLOCK = 2000; +export const NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS = 10000; +export const SHOW_POLL_RESULTS_ANIMATION_DURATION = 200; // ms +/* +To handle undelivered SDK messages, we follow this approach: if the "end poll" message is not received, +we wait an additional 2 seconds before removing the poll forcefully to resolve the issue and prevent it +from persisting in the user interface. +*/ +export const EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT = 2000; // ms + /** * STREAM MANAGER */ @@ -147,6 +170,7 @@ export const STREAM_ACTION_NAME = { QUIZ: 'quiz', CELEBRATION: 'celebration', NOTICE: 'notice', + POLL: 'poll', PRODUCT: 'product', AMAZON_PRODUCT: 'amazon_product' }; @@ -180,11 +204,18 @@ export const AMAZON_PRODUCT_DATA_KEYS = { PRODUCT_PAGE_NUMBER: 'productPageNumber' }; +export const POLL_DATA_KEYS = { + QUESTION: 'question', + ANSWERS: 'answers', + DURATION: 'duration' +}; + export const LOCALSTORAGE_ENABLED_STREAM_ACTIONS = [ STREAM_ACTION_NAME.QUIZ, STREAM_ACTION_NAME.CELEBRATION, STREAM_ACTION_NAME.NOTICE, - STREAM_ACTION_NAME.PRODUCT + STREAM_ACTION_NAME.PRODUCT, + STREAM_ACTION_NAME.POLL ]; /** @@ -278,6 +309,15 @@ export const STREAM_MANAGER_ACTION_LIMITS = { [STREAM_ACTION_NAME.CELEBRATION]: {}, [STREAM_ACTION_NAME.AMAZON_PRODUCT]: { [AMAZON_PRODUCT_DATA_KEYS.KEYWORD]: { maxCharLength: 150 } + }, + [STREAM_ACTION_NAME.POLL]: { + [POLL_DATA_KEYS.ANSWERS]: { + min: 2, // count + max: 5, // count + maxCharLength: 40 + }, + [POLL_DATA_KEYS.QUESTION]: { maxCharLength: 256 }, // TENTATIVE + [POLL_DATA_KEYS.DURATION]: { min: 10, max: 120 } // seconds } }; @@ -321,4 +361,3 @@ export const MAX_AVATAR_COUNT = 14; * Stream Manager page, Following section */ export const STREAM_MANAGER_DEFAULT_TAB = 0; -export const STREAM_MANAGER_WEB_BROADCAST_TAB = 1; diff --git a/web-ui/src/content/streamManager.json b/web-ui/src/content/streamManager.json index f7f2fea6..53935f4f 100644 --- a/web-ui/src/content/streamManager.json +++ b/web-ui/src/content/streamManager.json @@ -84,6 +84,9 @@ "quiz": { "confirm_text": "Start quiz", "question": "Question", + "answers_input_name_attribute": "streamManagerActionQuizFormAnswers", + "question_input_name_attribute": "streamManagerActionQuizFormQuestion", + "duration_input_name_attribute": "streamManagerActionFormDuration", "answers": "Answers", "answer": "Answer", "add_answer": "Add answer", @@ -113,6 +116,28 @@ "message": "Message", "duration": "Duration" }, + "poll": { + "confirm_text": "Start poll", + "question": "Question", + "answers_input_name_attribute": "streamManagerActionPollFormAnswers", + "question_input_name_attribute": "streamManagerActionPollFormQuestion", + "duration_input_name_attribute": "streamManagerActionFormDuration", + "default_label": "Host a live poll", + "active_label": "Hosting a live poll", + "modal_form_title": "Host a poll", + "answers": "Options", + "title": "Title", + "message": "Message", + "duration": "Duration", + "poll_results": "Poll results", + "total_votes": "Total votes", + "votes": "votes", + "winner": "Winner", + "vote": "Vote", + "leave_page": "Leave page", + "confirm_leave_page": "Are you sure you want to leave this page? You will miss votes while you are away.", + "showing_results": "Showing results" + }, "celebration": { "default_label": "Trigger", "active_label": "Triggering" @@ -120,7 +145,8 @@ "input_error": { "enter_valid_url": "Enter a valid URL.", "max_length_exceeded": "Maximum length exceeded.", - "select_correct_answer": "Select a correct answer." + "select_correct_answer": "Select a correct answer.", + "enter_a_unique_option": "Enter a unique option." }, "on": "On", "off": "Off" @@ -132,6 +158,7 @@ "started_notice": "Show a notice started", "started_product": "Feature a product started", "started_quiz": "Host a quiz started", + "started_poll": "Host a poll started", "stream_action_saved": "Stream action successfully saved" }, "error": { diff --git a/web-ui/src/contexts/Broadcast/Broadcast.jsx b/web-ui/src/contexts/Broadcast/Broadcast.jsx index 99b48766..41d23d41 100644 --- a/web-ui/src/contexts/Broadcast/Broadcast.jsx +++ b/web-ui/src/contexts/Broadcast/Broadcast.jsx @@ -336,7 +336,7 @@ export const Provider = ({ }, [success, notifySuccess]); useEffect(() => { - if (isBlocked) { + if (isBlocked && isBroadcasting) { openModal({ content: { confirmText: $content.leave_page, @@ -353,7 +353,7 @@ export const Provider = ({ onCancel }); } - }, [isBlocked, onCancel, onConfirm, openModal, isMobile]); + }, [isBlocked, onCancel, onConfirm, openModal, isMobile, isBroadcasting]); const value = useMemo( () => ({ diff --git a/web-ui/src/contexts/Chat.jsx b/web-ui/src/contexts/Chat.jsx new file mode 100644 index 00000000..85ca1406 --- /dev/null +++ b/web-ui/src/contexts/Chat.jsx @@ -0,0 +1,649 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState +} from 'react'; +import PropTypes from 'prop-types'; +import { useLocation } from 'react-router-dom'; + +import { channel as $channelContent } from '../content'; +import { useUser } from './User'; +import useContextHook from './useContextHook'; +import { useChannel } from './Channel'; +import { useNotif } from './Notification'; +import useChatActions from '../pages/Channel/Chat/useChatConnection/useChatActions'; +import { + CHAT_LOG_LEVELS, + MAX_RECONNECT_ATTEMPTS, + STREAM_ACTION_NAME +} from '../constants'; +import { ivsChatWebSocketRegionOrUrl } from '../api/utils'; +import { + CHAT_USER_ROLE, + requestChatToken +} from '../pages/Channel/Chat/useChatConnection/utils'; +import { ChatRoom } from 'amazon-ivs-chat-messaging'; +import { + extractChannelIdfromChannelArn, + updateVotes, + isVotingBlocked +} from '../utils'; +import { usePoll } from './StreamManagerActions/Poll'; +import { CHAT_MESSAGE_EVENT_TYPES } from '../constants'; + +const { + SEND_MESSAGE, + START_POLL, + END_POLL, + SUBMIT_VOTE, + SEND_VOTE_STATS, + HEART_BEAT +} = CHAT_MESSAGE_EVENT_TYPES; + +const $content = $channelContent.chat; + +const { INFO: info, DEBUG: debug } = CHAT_LOG_LEVELS; + +const Context = createContext(null); +Context.displayName = 'Chat'; + +/** + * @typedef {Object} SenderAttributes + * @property {string} avatar + * @property {string} color + * @property {string} displayName + * + * @typedef {Object} Sender + * @property {SenderAttributes} Attributes + * @property {string} UserId + * + * @typedef {Object} Message + * @property {object|null=} Attributes + * @property {string} Content + * @property {string} Id + * @property {string=} RequestId + * @property {Date} SendTime + * @property {Sender} Sender + * @property {string} Type + * + * @typedef {Array} Messages + */ + +const actionTypes = { + INIT_MESSAGES: 'INIT_MESSAGES', + ADD_MESSAGE: 'ADD_MESSAGE', + DELETE_MESSAGE: 'DELETE_MESSAGE', + DELETE_MESSAGES_BY_USER_ID: 'DELETE_MESSAGES_BY_USER_ID' +}; + +const reducer = (messages, action) => { + switch (action.type) { + case actionTypes.INIT_MESSAGES: { + return action.initialMessages || []; + } + case actionTypes.ADD_MESSAGE: { + const { message: newMessage, isOwnMessage } = action; + + return [...messages, { ...newMessage, isOwnMessage }]; + } + case actionTypes.DELETE_MESSAGE: { + const { messageId: messageIdToDelete, deletedMessageIds } = action; + const wasDeletedByUser = + deletedMessageIds.current.includes(messageIdToDelete); + + const newMessages = messages.reduce( + (acc, msg) => [ + ...acc, + msg.id === messageIdToDelete + ? { ...msg, isDeleted: true, wasDeletedByUser } + : msg + ], + [] + ); + + return newMessages; + } + case actionTypes.DELETE_MESSAGES_BY_USER_ID: { + const { userId: userIdToDelete } = action; + + const newMessages = messages.filter( + (msg) => msg.sender.attributes.channelArn !== userIdToDelete + ); + + return newMessages; + } + default: + throw new Error('Unexpected action type'); + } +}; + +export const Provider = ({ children }) => { + /** @type {[Messages, Function]} */ + const [messages, dispatch] = useReducer(reducer, []); + + /** + * `sentMessageIds` and `deletedMessageIds` have to be refs to avoid redefining `addMessage` which would reset the chat connection. + * `sentMessageIds` and `deletedMessageIds` are used to show the notifications upon message deletion. + * The corresponding messages are flagged respectively using the `isOwnMessage` and `wasDeletedByUser` booleans which are attached to `messages` (used for rendering). + */ + const sentMessageIds = useRef([]); + const deletedMessageIds = useRef([]); + const { userData, isSessionValid } = useUser(); + const { username: ownUsername } = userData || {}; + const savedMessages = useRef({}); + const { channelData, refreshChannelData } = useChannel(); + const { username: chatRoomOwnerUsername, isViewerBanned = false } = + channelData || {}; + const { notifyError, dismissNotif } = useNotif(); + const retryConnectionAttemptsCounterRef = useRef(0); + const chatCapabilities = useRef([]); + + // Connection State + const [hasConnectionError, setHasConnectionError] = useState(); + const [sendAttemptError, setSendAttemptError] = useState(); + const connection = useRef(null); + const [room, setRoom] = useState(null); + const isConnectionOpenRef = useRef(false); + + const isInitializingConnection = useRef(false); + const abortControllerRef = useRef(); + const isConnecting = isInitializingConnection.current; + + // Chat Actions + const [deletedMessage, setDeletedMessage] = useState(); + const { actions, chatUserRole, updateUserRole } = useChatActions({ + chatCapabilities, + isConnectionOpen: isConnectionOpenRef.current, + connection, + setSendAttemptError + }); + const isModerator = chatUserRole === CHAT_USER_ROLE.MODERATOR; + + // Poll Stream Action + const { + updatePollData, + votes, + hasPollEnded, + resetPollProps, + isActive, + clearPollLocalStorage, + setSelectedOption, + selectedOption, + showFinalResults, + duration, + question, + expiry, + startTime, + noVotesCaptured, + tieFound, + savedPollData, + saveVotesToLocalStorage, + savePollDataToLocalStorage, + dispatchPollState, + endPollAndResetPollProps + } = usePoll(); + const { pathname } = useLocation(); + + const isStreamManagerPage = pathname === '/manager'; + + const startPoll = useCallback( + async (pollStreamActionData) => { + const attributes = { + eventType: START_POLL, + pollStreamActionData: JSON.stringify(pollStreamActionData) + }; + + await actions.sendMessage(START_POLL, attributes); + return true; + }, + [actions] + ); + + const endPoll = useCallback( + ({ withTimeout, timeoutDuration } = {}) => { + const content = 'end poll'; + const attributes = { eventType: END_POLL }; + if (withTimeout) { + setTimeout( + () => actions.sendMessage(content, attributes), + timeoutDuration + ); + } else { + actions.sendMessage(content, attributes); + } + }, + [actions] + ); + + const sendHeartBeat = useCallback(() => { + if ( + isModerator && + isActive && + !showFinalResults && + !noVotesCaptured && + !tieFound + ) { + actions.sendMessage(HEART_BEAT, { + eventType: HEART_BEAT, + updatedVotes: JSON.stringify(votes), + duration: JSON.stringify(duration), + question: JSON.stringify(question), + expiry: JSON.stringify(expiry), + startTime: JSON.stringify(startTime), + voters: JSON.stringify(savedPollData?.voters || {}) + }); + } + }, [ + actions, + duration, + expiry, + isActive, + isModerator, + noVotesCaptured, + question, + savedPollData?.voters, + showFinalResults, + startTime, + tieFound, + votes + ]); + + useEffect(() => { + let heartBeatIntervalId = null; + if (isActive) { + heartBeatIntervalId = setInterval(() => { + sendHeartBeat(); + }, 4000); + } + + return () => { + if (heartBeatIntervalId !== null) { + clearInterval(heartBeatIntervalId); + } + }; + }, [isActive, sendHeartBeat]); + + const initMessages = useCallback(() => { + const initialMessages = savedMessages.current[chatRoomOwnerUsername] || []; + + dispatch({ type: actionTypes.INIT_MESSAGES, initialMessages }); + }, [chatRoomOwnerUsername]); + + const addMessage = useCallback( + (message) => { + const isOwnMessage = ownUsername === message.sender.userId; + + // Upon receiving a new message, we detect if the message was sent by the current user + if (isOwnMessage) sentMessageIds.current.push(message.id); + + dispatch({ type: actionTypes.ADD_MESSAGE, message, isOwnMessage }); + }, + [ownUsername] + ); + + const removeMessage = useCallback((messageId) => { + dispatch({ + type: actionTypes.DELETE_MESSAGE, + messageId, + deletedMessageIds + }); + }, []); + + const removeMessageByUserId = useCallback((userId) => { + dispatch({ + type: actionTypes.DELETE_MESSAGES_BY_USER_ID, + userId + }); + }, []); + + // messages local state + const handleDeleteMessage = useCallback( + (messageId) => { + removeMessage(messageId); + setDeletedMessage(messageId); + }, + [removeMessage] + ); + + const handleUserDisconnect = useCallback( + (bannedUsername) => { + const bannedUserChannelId = + extractChannelIdfromChannelArn(bannedUsername); + + if (bannedUserChannelId === userData?.trackingId.toLowerCase()) { + // This user has been banned + notifyError($content.notifications.error.you_have_been_banned); + refreshChannelData(); + } + }, + [notifyError, refreshChannelData, userData?.trackingId] + ); + + const disconnect = useCallback(() => { + refreshChannelData(); + setRoom(null); + connection.current = null; + chatCapabilities.current = null; + isInitializingConnection.current = false; + isConnectionOpenRef.current = false; + }, [refreshChannelData]); + + const connect = useCallback(() => { + if ( + isViewerBanned !== false || + !chatRoomOwnerUsername || + isInitializingConnection.current + ) + return; + + // Clean up previous connection resources + abortControllerRef.current = new AbortController(); + if (connection.current) disconnect(); + + isInitializingConnection.current = true; + setHasConnectionError(false); + + // create a new instance of chat room + const { signal } = abortControllerRef.current; + const room = new ChatRoom({ + regionOrUrl: ivsChatWebSocketRegionOrUrl, + maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, + tokenProvider: async () => { + const data = await requestChatToken(chatRoomOwnerUsername, signal); + + if (data?.error) { + retryConnectionAttemptsCounterRef.current += 1; + if ( + retryConnectionAttemptsCounterRef.current === MAX_RECONNECT_ATTEMPTS + ) { + isInitializingConnection.current = false; + notifyError($content.notifications.error.error_loading_chat, { + withTimeout: false + }); + setHasConnectionError(true); + } + } else { + chatCapabilities.current = data.capabilities; + } + + return { + ...data, + ...(!data?.error && { + sessionExpirationTime: new Date(data.sessionExpirationTime) + }) + }; + } + }); + + room.logLevel = process.env.REACT_APP_STAGE === 'prod' ? info : debug; + room.connect(); + setRoom(room); + connection.current = room; + isConnectionOpenRef.current = true; + isInitializingConnection.current = false; + }, [chatRoomOwnerUsername, disconnect, isViewerBanned, notifyError]); + + // Initialize connection + useEffect(() => { + connect(); + + return disconnect; + }, [connect, disconnect]); + + useEffect(() => { + // If chat room listeners are not available, do not continue + if (!room || !room.addListener) { + return; + } + + const unsubscribeOnConnect = room.addListener('connect', () => { + updateUserRole(); + dismissNotif(); + }); + + const unsubscribeOnDisconnect = room.addListener('disconnect', () => { + isConnectionOpenRef.current = false; + connection.current = null; + setRoom(null); + chatCapabilities.current = []; + + updateUserRole(); + }); + + const unsubscribeOnUserDisconnect = room.addListener( + 'userDisconnect', + (event) => { + const { trackingId } = userData; + const { userId: bannedUserId } = event; + + handleUserDisconnect(bannedUserId); + + const bannedUserChannelId = + extractChannelIdfromChannelArn(bannedUserId); + if (bannedUserChannelId !== trackingId.toLowerCase()) { + removeMessageByUserId(bannedUserId); + } + } + ); + + const unsubscribeOnMessage = room.addListener('message', (message) => { + const { + attributes: { + pollStreamActionData = undefined, + eventType = undefined, + voter = undefined, + option = undefined + } + } = message; + switch (eventType) { + case HEART_BEAT: + if ((isModerator && isStreamManagerPage) || hasPollEnded) return; + + const date = JSON.parse(message.attributes.startTime); + const currentTime = Date.now(); + const delay = (currentTime - date) / 1000; + + updatePollData({ + duration: Number(JSON.parse(message.attributes.duration)), + question: JSON.parse(message.attributes.question), + votes: JSON.parse(message.attributes.updatedVotes), + isActive: true, + expiry: JSON.parse(message.attributes.expiry), + startTime: JSON.parse(message.attributes.startTime), + delay + }); + + const votersList = JSON.parse(message.attributes.voters); + + if (!selectedOption && userData?.trackingId in votersList) { + const savedVote = votersList[userData?.trackingId]; + if (savedVote) { + setSelectedOption(savedVote); + dispatchPollState({ isVoting: false }); + } + } + break; + case SEND_VOTE_STATS: + const updatedVotes = JSON.parse(message.attributes.updatedVotes); + updatePollData({ votes: updatedVotes }); + break; + case SUBMIT_VOTE: + const shouldBlockVote = isVotingBlocked( + JSON.parse(message.attributes.duration), + JSON.parse(message.attributes.startTime) + ); + + const canProcessVote = + isModerator && pathname === '/manager' && !shouldBlockVote; + + if (canProcessVote) { + const currentVotes = updateVotes(message, votes); + updatePollData({ votes: currentVotes }); + + saveVotesToLocalStorage(currentVotes, { [voter]: option }); + + actions.sendMessage(SEND_VOTE_STATS, { + eventType: SEND_VOTE_STATS, + updatedVotes: JSON.stringify(currentVotes) + }); + } + break; + case START_POLL: + const { + votes: options, + duration, + question, + expiry, + startTime, + delay: del = 0 + } = JSON.parse(pollStreamActionData); + + if (isModerator && isStreamManagerPage) { + savePollDataToLocalStorage({ + duration, + expiry, + startTime, + question, + votes: options, + voters: {}, + isActive: true, + name: STREAM_ACTION_NAME.POLL + }); + } + + updatePollData({ + duration, + question, + votes: options, + isActive: true, + expiry, + startTime, + delay: del + }); + break; + case END_POLL: + endPollAndResetPollProps(); + break; + case SEND_MESSAGE: + addMessage(message); + break; + default: + break; + } + }); + + const unsubscribeOnMessageDelete = room.addListener( + 'messageDelete', + (deletedMessage) => { + const { + attributes: { MessageID }, + reason + } = deletedMessage; + handleDeleteMessage(MessageID, reason); + } + ); + + return () => { + unsubscribeOnConnect(); + unsubscribeOnDisconnect(); + unsubscribeOnMessage(); + unsubscribeOnMessageDelete(); + unsubscribeOnUserDisconnect(); + }; + }, [ + addMessage, + room, + updateUserRole, + dismissNotif, + handleDeleteMessage, + handleUserDisconnect, + userData, + removeMessageByUserId, + updatePollData, + resetPollProps, + hasPollEnded, + isModerator, + pathname, + votes, + actions, + isActive, + selectedOption, + setSelectedOption, + saveVotesToLocalStorage, + savedPollData, + clearPollLocalStorage, + isStreamManagerPage, + savePollDataToLocalStorage, + dispatchPollState, + endPollAndResetPollProps + ]); + + // We are saving the chat messages in local state for only the currently signed-in user's chat room, + // and removing them from local state once the user has signed out + useEffect(() => { + if (isSessionValid) { + if ( + ownUsername && + chatRoomOwnerUsername && + chatRoomOwnerUsername === ownUsername + ) { + savedMessages.current[ownUsername] = messages.map((message) => ({ + ...message, + isPreloaded: true + })); + } + } else { + savedMessages.current = {}; + } + }, [isSessionValid, messages, ownUsername, chatRoomOwnerUsername]); + + const value = useMemo( + () => ({ + addMessage, + deletedMessageIds, + initMessages, + messages, + removeMessage, + removeMessageByUserId, + sentMessageIds, + // chat Actions + actions, + chatUserRole, + hasConnectionError, + isConnecting, + sendAttemptError, + isModerator, + startPoll, + endPoll, + deletedMessage, + setDeletedMessage + }), + [ + actions, + addMessage, + chatUserRole, + hasConnectionError, + initMessages, + isConnecting, + messages, + removeMessage, + removeMessageByUserId, + sendAttemptError, + isModerator, + startPoll, + endPoll, + deletedMessage, + setDeletedMessage + ] + ); + + return {children}; +}; + +Provider.propTypes = { children: PropTypes.node.isRequired }; + +export const useChat = () => useContextHook(Context); diff --git a/web-ui/src/contexts/ChatMessages.jsx b/web-ui/src/contexts/ChatMessages.jsx deleted file mode 100644 index bd32b27f..00000000 --- a/web-ui/src/contexts/ChatMessages.jsx +++ /dev/null @@ -1,173 +0,0 @@ -import { - createContext, - useCallback, - useEffect, - useMemo, - useReducer, - useRef -} from 'react'; -import PropTypes from 'prop-types'; - -import { useUser } from './User'; -import useContextHook from './useContextHook'; - -const Context = createContext(null); -Context.displayName = 'ChatMessages'; - -/** - * @typedef {Object} SenderAttributes - * @property {string} avatar - * @property {string} color - * @property {string} displayName - * - * @typedef {Object} Sender - * @property {SenderAttributes} Attributes - * @property {string} UserId - * - * @typedef {Object} Message - * @property {object|null=} Attributes - * @property {string} Content - * @property {string} Id - * @property {string=} RequestId - * @property {Date} SendTime - * @property {Sender} Sender - * @property {string} Type - * - * @typedef {Array} Messages - */ - -const actionTypes = { - INIT_MESSAGES: 'INIT_MESSAGES', - ADD_MESSAGE: 'ADD_MESSAGE', - DELETE_MESSAGE: 'DELETE_MESSAGE', - DELETE_MESSAGES_BY_USER_ID: 'DELETE_MESSAGES_BY_USER_ID' -}; - -const reducer = (messages, action) => { - switch (action.type) { - case actionTypes.INIT_MESSAGES: { - return action.initialMessages || []; - } - case actionTypes.ADD_MESSAGE: { - const { message: newMessage, isOwnMessage } = action; - - return [...messages, { ...newMessage, isOwnMessage }]; - } - case actionTypes.DELETE_MESSAGE: { - const { messageId: messageIdToDelete, deletedMessageIds } = action; - const wasDeletedByUser = - deletedMessageIds.current.includes(messageIdToDelete); - - const newMessages = messages.reduce( - (acc, msg) => [ - ...acc, - msg.id === messageIdToDelete - ? { ...msg, isDeleted: true, wasDeletedByUser } - : msg - ], - [] - ); - - return newMessages; - } - case actionTypes.DELETE_MESSAGES_BY_USER_ID: { - const { userId: userIdToDelete } = action; - - const newMessages = messages.filter( - (msg) => msg.sender.attributes.channelArn !== userIdToDelete - ); - - return newMessages; - } - default: - throw new Error('Unexpected action type'); - } -}; - -export const Provider = ({ children }) => { - /** @type {[Messages, Function]} */ - const [messages, dispatch] = useReducer(reducer, []); - /** - * `sentMessageIds` and `deletedMessageIds` have to be refs to avoid redefining `addMessage` which would reset the chat connection. - * `sentMessageIds` and `deletedMessageIds` are used to show the notifications upon message deletion. - * The corresponding messages are flagged respectively using the `isOwnMessage` and `wasDeletedByUser` booleans which are attached to `messages` (used for rendering). - */ - const sentMessageIds = useRef([]); - const deletedMessageIds = useRef([]); - const { userData, isSessionValid } = useUser(); - const { username: ownUsername } = userData || {}; - const chatRoomOwnerUsername = useRef(); - const savedMessages = useRef({}); - - const initMessages = useCallback((chatRoomOwner) => { - const initialMessages = savedMessages.current[chatRoomOwner] || []; - - chatRoomOwnerUsername.current = chatRoomOwner; - dispatch({ type: actionTypes.INIT_MESSAGES, initialMessages }); - }, []); - - const addMessage = useCallback( - (message) => { - const isOwnMessage = ownUsername === message.sender.userId; - - // Upon receiving a new message, we detect if the message was sent by the current user - if (isOwnMessage) sentMessageIds.current.push(message.id); - - dispatch({ type: actionTypes.ADD_MESSAGE, message, isOwnMessage }); - }, - [ownUsername] - ); - - const removeMessage = useCallback((messageId) => { - dispatch({ - type: actionTypes.DELETE_MESSAGE, - messageId, - deletedMessageIds - }); - }, []); - - const removeMessageByUserId = useCallback((userId) => { - dispatch({ - type: actionTypes.DELETE_MESSAGES_BY_USER_ID, - userId - }); - }, []); - - // We are saving the chat messages in local state for only the currently signed-in user's chat room, - // and removing them from local state once the user has signed out - useEffect(() => { - if (isSessionValid) { - if ( - ownUsername && - chatRoomOwnerUsername.current && - chatRoomOwnerUsername.current === ownUsername - ) { - savedMessages.current[ownUsername] = messages.map((message) => ({ - ...message, - isPreloaded: true - })); - } - } else { - savedMessages.current = {}; - } - }, [isSessionValid, messages, ownUsername]); - - const value = useMemo( - () => ({ - addMessage, - deletedMessageIds, - initMessages, - messages, - removeMessage, - removeMessageByUserId, - sentMessageIds - }), - [addMessage, initMessages, messages, removeMessage, removeMessageByUserId] - ); - - return {children}; -}; - -Provider.propTypes = { children: PropTypes.node.isRequired }; - -export const useChatMessages = () => useContextHook(Context); diff --git a/web-ui/src/contexts/StreamManagerActions/Poll.jsx b/web-ui/src/contexts/StreamManagerActions/Poll.jsx new file mode 100644 index 00000000..bb9934d8 --- /dev/null +++ b/web-ui/src/contexts/StreamManagerActions/Poll.jsx @@ -0,0 +1,441 @@ +import PropTypes from 'prop-types'; +import { + createContext, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState +} from 'react'; + +import useContextHook from '../../contexts/useContextHook'; +import useLocalStorage from '../../hooks/useLocalStorage'; +import { extractChannelIdfromChannelArn } from '../../utils'; +import { pack, unpack } from '../../helpers/streamActionHelpers'; +import { useChannel } from '../Channel'; +import { useUser } from '../User'; +import { useLocation } from 'react-router-dom'; +import { + NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS, + EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT +} from '../../constants'; + +const COMPOSER_HEIGHT = 92; +const SPACE_BETWEEN_COMPOSER_AND_POLL = 100; + +const Context = createContext(null); +Context.displayName = 'Poll'; + +const POLL_TAB_LABEL = 'Live poll'; + +const initialPollProps = { + votes: [], + question: null, + isActive: false, + duration: 0, + expiry: null, + startTime: null, + delay: 0 +}; + +const initialPollState = { + isSubmitting: false, + isVoting: true, + isExpanded: true, + pollHeight: 0, + pollRef: undefined, + hasListReordered: false, + showFinalResults: false, + hasPollEnded: false, + noVotesCaptured: false, + tieFound: false, + hasScrollbar: false +}; + +const localStorageInitialState = { + ...initialPollProps, + voters: {} +}; + +export const Provider = ({ children }) => { + const forceResetPollPropsTimerRef = useRef(); + const stopPollTimerRef = useRef(); + const [composerRefState, setComposerRefState] = useState(); + const shouldAnimateListRef = useRef(false); + const [selectedOption, setSelectedOption] = useState(); + const { channelData } = useChannel(); + const { username, channelArn = '', isViewerBanned } = channelData || {}; + const { userData, isSessionValid } = useUser(); + const channelId = extractChannelIdfromChannelArn(channelArn); + const isModerator = channelId === userData?.trackingId; + const { pathname } = useLocation(); + const isStreamManagerPage = pathname === '/manager'; + + // Poll UI states + const [pollState, dispatchPollState] = useReducer( + (prevState, nextState) => ({ ...prevState, ...nextState }), + initialPollState + ); + // Active poll props + const [pollProps, dispatchPollProps] = useReducer( + (prevState, nextState) => ({ ...prevState, ...nextState }), + initialPollProps + ); + + const pollHasEnded = useCallback(() => { + dispatchPollState({ hasPollEnded: true }); + }, []); + + const { votes, question, isActive, duration, expiry, startTime, delay } = + pollProps; + const { + isSubmitting, + isVoting, + isExpanded, + pollHeight, + pollRef, + showFinalResults, + hasListReordered, + hasPollEnded, + noVotesCaptured, + tieFound, + hasScrollbar + } = pollState; + + const { value: savedPollData, set: savePollDataToLocalStorage } = + useLocalStorage({ + key: username, + initialValue: localStorageInitialState, + options: { + keyPrefix: 'poll', + serialize: pack, + deserialize: unpack + } + }); + + const showFinalResultActionButton = () => ({ + duration: 10, + expiry: new Date(Date.now() + 10 * 1000).toISOString() + }); + + const updatePollData = ({ + votes, + duration, + question, + expiry, + startTime, + isActive, + delay = 0 + }) => { + const props = { + ...(duration && { duration }), + ...(question && { question }), + ...(votes && { votes }), + ...(expiry && { expiry }), + ...(isActive && { isActive }), + ...(startTime && { startTime }), + ...(delay && { delay }) + }; + + dispatchPollProps(props); + }; + + const clearPollLocalStorage = useCallback(() => { + savePollDataToLocalStorage(localStorageInitialState); + }, [savePollDataToLocalStorage]); + + const resetPollProps = useCallback(() => { + if (stopPollTimerRef.current) clearTimeout(stopPollTimerRef.current); + clearPollLocalStorage(); + dispatchPollProps(initialPollProps); + dispatchPollState(initialPollState); + setSelectedOption(); + shouldAnimateListRef.current = false; + stopPollTimerRef.current = undefined; + forceResetPollPropsTimerRef.current = undefined; + }, [clearPollLocalStorage]); + + const hasMounted = useRef(false); + + useEffect(() => { + if (!channelData || hasMounted.current) return; + + if (isModerator && isStreamManagerPage && savedPollData?.isActive) { + hasMounted.current = true; + const { + question, + duration, + startTime, + votes: options, + expiry + } = savedPollData; + + updatePollData({ + expiry, + startTime, + question, + duration, + isActive: true, + votes: options, + delay + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channelData, savedPollData]); + + useEffect(() => { + let timeout; + + if (savedPollData?.hasPollEnded && !hasPollEnded) { + pollHasEnded(); + return; + } + + if (duration && !hasPollEnded && !savedPollData?.hasPollEnded) { + const pollDuration = duration * 1000 - delay * 1000; + + timeout = setTimeout(() => { + dispatchPollState({ hasPollEnded: true }); + }, pollDuration); + } + + return () => { + clearTimeout(timeout); + }; + }, [ + delay, + duration, + hasPollEnded, + pollHasEnded, + savedPollData?.hasPollEnded + ]); + + useEffect(() => { + if (showFinalResults) { + dispatchPollState({ hasListReordered: true }); + } + }, [showFinalResults]); + + const checkForTie = (votes) => { + const maxVote = Math.max(...votes.map((vote) => vote.count)); + const count = votes.filter((vote) => vote.count === maxVote).length; + + return count > 1; + }; + + useEffect(() => { + if (hasPollEnded && !noVotesCaptured && !showFinalResults && !tieFound) { + const noVotesCaptured = votes.every((vote) => vote.count === 0); + const hasTie = checkForTie(votes); + + if (noVotesCaptured) { + dispatchPollState({ noVotesCaptured: true }); + } else { + if (hasTie) { + dispatchPollState({ tieFound: true }); + } else { + dispatchPollState({ showFinalResults: true }); + } + const sortedVotes = votes.sort((a, b) => + a.count < b.count ? 1 : a.count > b.count ? -1 : 0 + ); + dispatchPollProps({ + votes: sortedVotes + }); + } + } + }, [hasPollEnded, noVotesCaptured, showFinalResults, tieFound, votes]); + + // The value set here will determine the min height of the chat + poll container. + // The reason its calculated this way is because the poll has a position: absolute + const containerMinHeight = `${ + pollHeight + SPACE_BETWEEN_COMPOSER_AND_POLL + COMPOSER_HEIGHT + }px`; + + useEffect(() => { + if (pollRef) { + dispatchPollState({ pollHeight: pollRef.offsetHeight }); + } + }, [pollRef, isExpanded]); + + const getPollDetails = (votes) => { + return votes.reduce( + (acc, vote) => { + if (!acc.highestCountOption) { + acc.highestCountOption = vote; + } else { + if (vote.count > acc.highestCountOption.count) { + acc.highestCountOption = vote; + } + } + + acc.totalVotes += vote.count; + return acc; + }, + { totalVotes: 0, highestCountOption: null } + ); + }; + + const { highestCountOption, totalVotes } = getPollDetails(votes); + + const saveVotesToLocalStorage = useCallback( + (currentVotes, voter) => { + savePollDataToLocalStorage({ + ...savedPollData, + votes: currentVotes, + voters: { + ...savedPollData.voters, + ...voter + } + }); + }, + [savePollDataToLocalStorage, savedPollData] + ); + + const updateSavedPollPropsOnTimerExpiry = useCallback(() => { + const { duration, expiry } = showFinalResultActionButton(); + savePollDataToLocalStorage({ + ...savedPollData, + duration, + expiry, + hasPollEnded: true + }); + }, [savePollDataToLocalStorage, savedPollData]); + + const isAbleToVote = + isVoting && !showFinalResults && !noVotesCaptured && !isViewerBanned; + + const shouldRenderRadioInput = + isAbleToVote && isSessionValid && !isStreamManagerPage; + + const shouldRenderVoteButton = isAbleToVote && !!userData; + + const shouldRenderProgressbar = + !showFinalResults && !noVotesCaptured && !tieFound && startTime; + + const endPollAndResetPollProps = useCallback(() => { + clearTimeout(forceResetPollPropsTimerRef.current); + dispatchPollProps({ isActive: false }); + setTimeout(resetPollProps, 100); + hasMounted.current = false; + }, [resetPollProps]); + + useEffect(() => { + if (hasPollEnded) { + if (forceResetPollPropsTimerRef.current) { + clearTimeout(forceResetPollPropsTimerRef.current); + } else { + forceResetPollPropsTimerRef.current = setTimeout(() => { + if (isActive) endPollAndResetPollProps(); + }, NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS + EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT); + } + } + }, [endPollAndResetPollProps, isActive, hasPollEnded]); + + useEffect(() => { + /* + This code ensures that any expired poll is removed from the user interface (UI) when the user closes all instances of the UGC app and revist the app again. + */ + const isPollExpired = + startTime + + duration * 1000 + + NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS + + EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT < + Date.now(); + + if (isPollExpired && isActive) { + endPollAndResetPollProps(); + } + }, [duration, startTime, isActive, endPollAndResetPollProps]); + + const value = useMemo( + () => ({ + isExpanded, + pollHeight, + containerMinHeight, + showFinalResults, + votes, + highestCountOption, + totalVotes, + hasListReordered, + shouldAnimateListRef, + question, + isActive, + startTime, + duration, + selectedOption, + setSelectedOption, + isSubmitting, + isVoting, + updatePollData, + expiry, + resetPollProps, + noVotesCaptured, + tieFound, + delay, + clearPollLocalStorage, + pollTabLabel: POLL_TAB_LABEL, + showFinalResultActionButton, + hasPollEnded, + stopPollTimerRef, + pollHasEnded, + saveVotesToLocalStorage, + savedPollData, + savePollDataToLocalStorage, + updateSavedPollPropsOnTimerExpiry, + hasScrollbar, + composerRefState, + setComposerRefState, + dispatchPollState, + shouldRenderRadioInput, + shouldRenderVoteButton, + endPollAndResetPollProps, + hasVotes: votes.length > 0, + shouldRenderProgressbar + }), + [ + isExpanded, + pollHeight, + containerMinHeight, + showFinalResults, + votes, + highestCountOption, + totalVotes, + hasListReordered, + question, + isActive, + startTime, + duration, + selectedOption, + isSubmitting, + isVoting, + expiry, + resetPollProps, + noVotesCaptured, + tieFound, + delay, + clearPollLocalStorage, + hasPollEnded, + pollHasEnded, + saveVotesToLocalStorage, + savedPollData, + savePollDataToLocalStorage, + updateSavedPollPropsOnTimerExpiry, + hasScrollbar, + composerRefState, + shouldRenderRadioInput, + shouldRenderVoteButton, + endPollAndResetPollProps, + shouldRenderProgressbar + ] + ); + + return {children}; +}; + +Provider.propTypes = { + children: PropTypes.node.isRequired +}; + +export const usePoll = () => useContextHook(Context); diff --git a/web-ui/src/contexts/StreamManagerActions/StreamManagerActions.jsx b/web-ui/src/contexts/StreamManagerActions/StreamManagerActions.jsx index 5f309413..bb9af891 100644 --- a/web-ui/src/contexts/StreamManagerActions/StreamManagerActions.jsx +++ b/web-ui/src/contexts/StreamManagerActions/StreamManagerActions.jsx @@ -7,14 +7,18 @@ import { MODAL_TYPE, useModal } from '../Modal'; import { pack } from '../../helpers/streamActionHelpers'; import { STREAM_ACTION_NAME, - LOCALSTORAGE_ENABLED_STREAM_ACTIONS + LOCALSTORAGE_ENABLED_STREAM_ACTIONS, + NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS } from '../../constants'; import { streamManager as $content } from '../../content'; +import { useChat } from '../Chat'; import { useNotif } from '../Notification'; import useContextHook from '../useContextHook'; import useStreamManagerActionsLocalStorage from './useStreamManagerActionsLocalStorage'; import useStreamManagerActionValidation from './useStreamManagerActionValidation'; import useThrottledCallback from '../../hooks/useThrottledCallback'; +import { v4 as uuidv4 } from 'uuid'; +import { usePoll } from './Poll'; const Context = createContext(null); Context.displayName = 'StreamManagerActions'; @@ -24,14 +28,15 @@ Context.displayName = 'StreamManagerActions'; * The Provider takes */ export const Provider = ({ children }) => { + const { + isActive: isPollActive, + stopPollTimerRef, + pollHasEnded, + updateSavedPollPropsOnTimerExpiry + } = usePoll(); + const { startPoll, endPoll } = useChat(); const [isSendingStreamAction, setIsSendingStreamAction] = useState(false); - const [streamManagerActionData, setStreamManagerActionData] = useState( - DEFAULT_STREAM_MANAGER_ACTIONS_STATE - ); - const activeStreamManagerActionData = useMemo( - () => streamManagerActionData?._active || null, - [streamManagerActionData?._active] - ); + const { currentStreamManagerActionErrors, resetStreamManagerActionErrorData, @@ -84,6 +89,14 @@ export const Provider = ({ children }) => { updateStreamManagerActionData }); + const [streamManagerActionData, setStreamManagerActionData] = useState( + DEFAULT_STREAM_MANAGER_ACTIONS_STATE + ); + + const activeStreamManagerActionData = useMemo(() => { + return streamManagerActionData?._active || null; + }, [streamManagerActionData?._active]); + const shouldEnableLocalStorage = (actionName) => LOCALSTORAGE_ENABLED_STREAM_ACTIONS.includes(actionName); @@ -134,6 +147,9 @@ export const Provider = ({ children }) => { : undefined; dataToSave._active = { duration, expiry, name: actionName }; + + // End active poll stream action + if (isPollActive) cancelActivePoll(); } if (shouldEnableLocalStorage(actionName)) { @@ -159,6 +175,31 @@ export const Provider = ({ children }) => { 100 ); + /** + * Stops the currently active poll action + */ + const endPollOnExpiry = useCallback(() => { + pollHasEnded(); + updateSavedPollPropsOnTimerExpiry(); + stopPollTimerRef.current = setTimeout(() => { + endPoll(); + saveStreamManagerActionData((prevStoredData) => ({ + ...prevStoredData, + _active: undefined + })); + }, NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS); + }, [ + endPoll, + pollHasEnded, + saveStreamManagerActionData, + stopPollTimerRef, + updateSavedPollPropsOnTimerExpiry + ]); + + const cancelActivePoll = useCallback(async () => { + await endPoll(); + }, [endPoll]); + /** * Stops the currently active stream action, if one exists */ @@ -203,6 +244,67 @@ export const Provider = ({ children }) => { }); }, [updateStreamManagerActionData]); + /** + * Start Poll stream action + */ + + const sendPollStreamAction = useThrottledCallback( + async (actionName, data) => { + // End active stream actions + if ( + activeStreamManagerActionData && + actionName !== STREAM_ACTION_NAME.CELEBRATION + ) + stopStreamAction(); + + try { + setIsSendingStreamAction(true); + + const actionData = data[actionName]; + const { duration, question, answers } = actionData; + const expiry = + duration > 0 + ? new Date(Date.now() + duration * 1000).toISOString() + : undefined; + const startTime = Date.now(); + const pollStreamActionData = { + duration, + expiry, + startTime, + question, + votes: answers.reduce((acc, answer) => { + const option = { option: answer, count: 0, key: uuidv4() }; + acc.push(option); + return acc; + }, []), + voters: {}, + isActive: true + }; + const result = await startPoll(pollStreamActionData); + + // Save the form data only if the send request was successful + const dataToSave = data; + if (result) { + dataToSave._active = { duration, expiry, name: actionName }; + } + // Save data to stream manager local storage + if (shouldEnableLocalStorage(actionName)) { + saveStreamManagerActionData(dataToSave); + } + + setIsSendingStreamAction(false); + notifySuccess($content.notifications.success[`started_${actionName}`]); + + return result; + } catch (error) { + notifyErrorPortal( + $content.notifications.error.unable_to_start_stream_action + ); + } + }, + 100 + ); + /** * Opens a Stream Manager Action modal for a specific action name, * with the "content" provided in the modalData argument @@ -222,7 +324,21 @@ export const Provider = ({ children }) => { }; const onConfirm = async (data) => { - if (!validateStreamManagerActionData(data[actionName], actionName)) { + const shouldCheckForDuplicateValidaton = [ + STREAM_ACTION_NAME.POLL, + STREAM_ACTION_NAME.QUIZ + ].includes(actionName); + const options = { + enableDuplicateValidation: shouldCheckForDuplicateValidaton + }; + + if ( + !validateStreamManagerActionData( + data[actionName], + actionName, + options + ) + ) { notifyErrorPortal($content.notifications.error.unable_to_send); return false; @@ -230,7 +346,10 @@ export const Provider = ({ children }) => { resetStreamManagerActionErrorData(); - const result = await sendStreamAction(actionName, data); + const result = + actionName === STREAM_ACTION_NAME.POLL + ? await sendPollStreamAction(actionName, data) + : await sendStreamAction(actionName, data); return !!result; }; @@ -259,6 +378,7 @@ export const Provider = ({ children }) => { resetStreamManagerActionData, resetStreamManagerActionErrorData, saveStreamManagerActionData, + sendPollStreamAction, sendStreamAction, validateStreamManagerActionData ] @@ -282,7 +402,10 @@ export const Provider = ({ children }) => { isLoading, setIsLoading, isValidKeyword, - setIsValidKeyword + setIsValidKeyword, + setIsSendingStreamAction, + endPollOnExpiry, + cancelActivePoll }), [ activeStreamManagerActionData, @@ -301,7 +424,10 @@ export const Provider = ({ children }) => { isValidKeyword, setIsValidKeyword, isLoading, - setIsLoading + setIsLoading, + setIsSendingStreamAction, + endPollOnExpiry, + cancelActivePoll ] ); diff --git a/web-ui/src/contexts/StreamManagerActions/useStreamManagerActionsLocalStorage.js b/web-ui/src/contexts/StreamManagerActions/useStreamManagerActionsLocalStorage.js index af9d97b0..9246adae 100644 --- a/web-ui/src/contexts/StreamManagerActions/useStreamManagerActionsLocalStorage.js +++ b/web-ui/src/contexts/StreamManagerActions/useStreamManagerActionsLocalStorage.js @@ -28,6 +28,7 @@ const useStreamManagerActionsLocalStorage = ({ deserialize: unpack } }); + const latestStoredStreamManagerActionData = useLatest( storedStreamManagerActionData ); @@ -36,20 +37,22 @@ const useStreamManagerActionsLocalStorage = ({ * Saves the form data in local storage */ const saveStreamManagerActionData = useCallback( - (dataOrFn) => + (dataOrFn) => { setStoredStreamManagerActionData((prevStoredData) => { const data = dataOrFn instanceof Function ? dataOrFn(prevStoredData) : dataOrFn; const shouldUpdate = JSON.stringify(prevStoredData) !== JSON.stringify(data); const dataToSave = shouldUpdate ? data : prevStoredData; + updateStreamManagerActionData({ dataOrFn: dataToSave, shouldValidate: false }); return dataToSave; - }), + }); + }, [setStoredStreamManagerActionData, updateStreamManagerActionData] ); @@ -105,7 +108,8 @@ const useStreamManagerActionsLocalStorage = ({ return { saveStreamManagerActionData, latestStoredStreamManagerActionData, - storedStreamManagerActionData + storedStreamManagerActionData, + setStoredStreamManagerActionData }; }; diff --git a/web-ui/src/contexts/StreamManagerActions/utils.js b/web-ui/src/contexts/StreamManagerActions/utils.js index 47c64b76..01fb9821 100644 --- a/web-ui/src/contexts/StreamManagerActions/utils.js +++ b/web-ui/src/contexts/StreamManagerActions/utils.js @@ -5,6 +5,7 @@ import { NOTICE_DATA_KEYS, PRODUCT_DATA_KEYS, QUIZ_DATA_KEYS, + POLL_DATA_KEYS, STREAM_ACTION_NAME, STREAM_MANAGER_ACTION_LIMITS } from '../../constants'; @@ -40,5 +41,14 @@ export const DEFAULT_STREAM_MANAGER_ACTIONS_STATE = { [AMAZON_PRODUCT_DATA_KEYS.PRODUCT_CHOICE]: {}, [AMAZON_PRODUCT_DATA_KEYS.PRODUCT_OPTIONS]: [], [AMAZON_PRODUCT_DATA_KEYS.PRODUCT_PAGE_NUMBER]: 1 + }, + [STREAM_ACTION_NAME.POLL]: { + [POLL_DATA_KEYS.QUESTION]: '', + [POLL_DATA_KEYS.ANSWERS]: Array( + STREAM_MANAGER_ACTION_LIMITS[STREAM_ACTION_NAME.POLL][ + POLL_DATA_KEYS.ANSWERS + ].min + ).fill(''), + [POLL_DATA_KEYS.DURATION]: 15 } }; diff --git a/web-ui/src/contexts/StreamManagerActions/validate.js b/web-ui/src/contexts/StreamManagerActions/validate.js index da628afb..3a647a4d 100644 --- a/web-ui/src/contexts/StreamManagerActions/validate.js +++ b/web-ui/src/contexts/StreamManagerActions/validate.js @@ -14,7 +14,8 @@ const STREAM_MANAGER_ACTION_FORMATS = { }, [STREAM_ACTION_NAME.NOTICE]: {}, [STREAM_ACTION_NAME.CELEBRATION]: {}, - [STREAM_ACTION_NAME.AMAZON_PRODUCT]: {} + [STREAM_ACTION_NAME.AMAZON_PRODUCT]: {}, + [STREAM_ACTION_NAME.POLL]: {} }; // Character Length Validator @@ -29,10 +30,30 @@ const validateUrl = (url) => { return validateLength(url, 0, 2048) && regex.test(url); }; +const getListOfDuplicateOptions = (value) => { + const _duplicatesIdxArray = []; + const answerCountMap = {}; + + value.forEach((option, idx) => { + if (option === '') return; + + const formattedString = option?.trim(); + answerCountMap[formattedString] = + formattedString in answerCountMap ? ++answerCountMap[formattedString] : 1; + + if (answerCountMap[formattedString] > 1) { + _duplicatesIdxArray.push(idx); + } + }); + + return _duplicatesIdxArray; +}; + // Main Validator const defaultValidationOptions = { disableFormatValidation: false, - disableLengthValidation: false + disableLengthValidation: false, + enableDuplicateValidation: false }; const validate = ( @@ -40,7 +61,8 @@ const validate = ( actionName, { disableFormatValidation = defaultValidationOptions.disableFormatValidation, - disableLengthValidation = defaultValidationOptions.disableLengthValidation + disableLengthValidation = defaultValidationOptions.disableLengthValidation, + enableDuplicateValidation = defaultValidationOptions.enableDuplicateValidation } = defaultValidationOptions ) => Object.entries(data).reduce((errors, [key, value]) => { @@ -71,6 +93,18 @@ const validate = ( } } + // Check whether answers/options are unique + if (enableDuplicateValidation) { + const listOfDuplicateOptions = getListOfDuplicateOptions(value); + + if (listOfDuplicateOptions.length) { + for (const duplicateIdx of listOfDuplicateOptions) { + messages[duplicateIdx] = $content.enter_a_unique_option; + isInvalid = true; + } + } + } + if (isInvalid) return { ...errors, [key]: messages }; } else { if ( diff --git a/web-ui/src/contexts/ViewerStreamActions.jsx b/web-ui/src/contexts/ViewerStreamActions.jsx index e26cdde4..ab732cdf 100644 --- a/web-ui/src/contexts/ViewerStreamActions.jsx +++ b/web-ui/src/contexts/ViewerStreamActions.jsx @@ -102,14 +102,16 @@ export const Provider = () => { currentViewerStreamActionName, currentViewerStreamActionTitle, setCurrentViewerAction, - shouldRenderActionInTab + shouldRenderActionInTab, + isChannelPageStackedView }), [ augmentedCurrentViewerStreamActionData, clearCurrentViewerAction, currentViewerStreamActionName, currentViewerStreamActionTitle, - shouldRenderActionInTab + shouldRenderActionInTab, + isChannelPageStackedView ] ); diff --git a/web-ui/src/hooks/usePrompt.js b/web-ui/src/hooks/usePrompt.js index c1afb59d..0569ee29 100644 --- a/web-ui/src/hooks/usePrompt.js +++ b/web-ui/src/hooks/usePrompt.js @@ -1,5 +1,5 @@ import { unstable_useBlocker as useBlocker } from 'react-router-dom'; -import { useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import useBeforeUnload from './useBeforeUnload'; @@ -22,10 +22,6 @@ const usePrompt = (when) => { if (isBlocked) blocker.reset(); }, [blocker, isBlocked]); - useEffect(() => { - if (isBlocked && !when) blocker.reset(); - }, [blocker, isBlocked, when]); - useBeforeUnload(when); return { isBlocked, onConfirm, onCancel }; diff --git a/web-ui/src/hooks/useResizeObserver.js b/web-ui/src/hooks/useResizeObserver.js index 84636c1a..835e17a9 100644 --- a/web-ui/src/hooks/useResizeObserver.js +++ b/web-ui/src/hooks/useResizeObserver.js @@ -23,7 +23,7 @@ const useResizeObserver = (targetRef, callback, isEnabled = true) => { const storedCallback = useLatest(callback); useLayoutEffect(() => { - const targetEl = targetRef.current; + const targetEl = targetRef?.current; if (!targetEl || !isEnabled) return; let didUnsubscribe = false; diff --git a/web-ui/src/pages/Channel/Channel.jsx b/web-ui/src/pages/Channel/Channel.jsx index 2be7d6eb..47a10354 100644 --- a/web-ui/src/pages/Channel/Channel.jsx +++ b/web-ui/src/pages/Channel/Channel.jsx @@ -1,9 +1,10 @@ import { motion } from 'framer-motion'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState, useRef } from 'react'; import { channel as $channelContent } from '../../content'; import { clsm } from '../../utils'; import { Provider as NotificationProvider } from '../../contexts/Notification'; +import { Provider as ChatProvider } from '../../contexts/Chat'; import { Provider as PlayerProvider } from './contexts/Player'; import { sanitizeAmazonProductData } from '../../helpers/streamActionHelpers'; import { STREAM_ACTION_NAME } from '../../constants'; @@ -23,6 +24,8 @@ import QuizViewerStreamAction from './ViewerStreamActions/QuizCard'; import Tabs from '../../components/Tabs/Tabs'; import useMount from '../../hooks/useMount'; import useResize from '../../hooks/useResize'; +import Poll from './Chat/Poll/Poll'; +import { usePoll } from '../../contexts/StreamManagerActions/Poll'; const DEFAULT_SELECTED_TAB_INDEX = 0; const CHAT_PANEL_TAB_INDEX = 1; @@ -38,8 +41,10 @@ const Channel = () => { currentViewerStreamActionName, currentViewerStreamActionTitle, setCurrentViewerAction, - shouldRenderActionInTab + shouldRenderActionInTab, + isChannelPageStackedView } = useViewerStreamActions(); + const { isActive: isPollActive, pollTabLabel, hasVotes } = usePoll(); const [selectedTabIndex, setSelectedTabIndex] = useState( DEFAULT_SELECTED_TAB_INDEX ); @@ -51,6 +56,9 @@ const Channel = () => { if (isSplitView) visibleChatWidth = 308; else if (isStackedView) visibleChatWidth = '100%'; + const isTabView = + shouldRenderActionInTab || (isPollActive && isChannelPageStackedView); + const updateChatSectionHeight = useCallback(() => { let chatSectionHeight = 200; @@ -60,13 +68,14 @@ const Channel = () => { * Therefore, we use the window.innerHeight instead; otherwise, we use the channel width. */ const { innerWidth, innerHeight } = window; - const { clientWidth: channelWidth } = channelRef.current; + const { clientWidth: channelWidth = 0 } = channelRef?.current || {}; const width = isMobileView ? innerWidth : channelWidth; chatSectionHeight = Math.max(innerHeight - (width * 9) / 16, 200); // chat section should be no less than 200px in height } - chatSectionRef.current.style.minHeight = `${chatSectionHeight}px`; + if (chatSectionRef.current) + chatSectionRef.current.style.minHeight = `${chatSectionHeight}px`; }, [isMobileView, isStackedView]); useResize(updateChatSectionHeight, { shouldCallOnMount: true }); @@ -159,59 +168,70 @@ const Channel = () => { ])} > - {shouldRenderActionInTab && ( + {isTabView && ( <> - {currentViewerStreamActionName === - STREAM_ACTION_NAME.QUIZ && ( - + {hasVotes && ( + + + + + )} - {[ - STREAM_ACTION_NAME.AMAZON_PRODUCT, - STREAM_ACTION_NAME.PRODUCT - ].includes(currentViewerStreamActionName) && ( -
- -
- )} + )} + {!isPollActive && + [ + STREAM_ACTION_NAME.AMAZON_PRODUCT, + STREAM_ACTION_NAME.PRODUCT + ].includes(currentViewerStreamActionName) && ( +
+ +
+ )}
)} - {selectedTabIndex === 0 && shouldRenderActionInTab && ( + {selectedTabIndex === 0 && isTabView && ( { - + + {!isTabView && hasVotes && } + +
diff --git a/web-ui/src/pages/Channel/Chat/Chat.jsx b/web-ui/src/pages/Channel/Chat/Chat.jsx index 42779270..f2a34d1e 100644 --- a/web-ui/src/pages/Channel/Chat/Chat.jsx +++ b/web-ui/src/pages/Channel/Chat/Chat.jsx @@ -1,17 +1,13 @@ -import { AnimatePresence } from 'framer-motion'; import { memo, useCallback, useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; +import { AnimatePresence } from 'framer-motion'; -import { - BANNED_USERNAME_CHANNEL_ID_SEPARATOR, - BREAKPOINTS, - MODERATOR_PILL_TIMEOUT -} from '../../../constants'; +import { BREAKPOINTS, MODERATOR_PILL_TIMEOUT } from '../../../constants'; import { channel as $channelContent } from '../../../content'; import { CHAT_USER_ROLE } from './useChatConnection/utils'; -import { clsm } from '../../../utils'; +import { clsm, extractChannelIdfromChannelArn } from '../../../utils'; import { useChannel } from '../../../contexts/Channel'; -import { useChatMessages } from '../../../contexts/ChatMessages'; +import { useChat } from '../../../contexts/Chat'; import { useNotif } from '../../../contexts/Notification'; import { useResponsiveDevice } from '../../../contexts/ResponsiveDevice'; import { useUser } from '../../../contexts/User'; @@ -21,18 +17,17 @@ import Composer from './Composer'; import ConnectingOverlay from './ConnectingOverlay'; import Messages from './Messages'; import Notification from '../../../components/Notification'; -import useChatConnection from './useChatConnection'; import useResizeObserver from '../../../hooks/useResizeObserver'; const $content = $channelContent.chat; const Chat = ({ shouldRunCelebration }) => { const [chatContainerDimensions, setChatContainerDimensions] = useState(); - const { channelData, isChannelLoading, refreshChannelData } = useChannel(); + const { channelData, isChannelLoading } = useChannel(); const { color: channelColor } = channelData || {}; const { isSessionValid, userData } = useUser(); - const { notifyError, notifyInfo, notifySuccess } = useNotif(); + const { notifyError, notifySuccess, notifyInfo } = useNotif(); const { isLandscape, isMobileView, @@ -47,70 +42,33 @@ const Chat = ({ shouldRunCelebration }) => { if (isSplitView) chatPopupParentEl = document.body; else if (isStackedView) chatPopupParentEl = mainRef.current; - /** - * Chat Event Handlers - */ - const { - deletedMessageIds, - removeMessage, - removeMessageByUserId, - sentMessageIds - } = useChatMessages(); - const handleDeleteMessage = useCallback( - (messageId) => { - removeMessage(messageId); - if (deletedMessageIds.current.includes(messageId)) { - notifySuccess($content.notifications.success.message_removed); - } else if (sentMessageIds.current.includes(messageId)) { - notifyError($content.notifications.error.your_message_was_removed); - } - }, - [ - deletedMessageIds, - notifyError, - notifySuccess, - removeMessage, - sentMessageIds - ] - ); - const handleUserDisconnect = useCallback( - (bannedUsername) => { - const bannedUserChannelId = bannedUsername - .toLowerCase() - .split(BANNED_USERNAME_CHANNEL_ID_SEPARATOR)[1]; - - if (bannedUserChannelId === userData?.trackingId) { - // This user has been banned - notifyError($content.notifications.error.you_have_been_banned); - refreshChannelData(); - } - }, - [notifyError, refreshChannelData, userData?.trackingId] - ); const { actions, chatUserRole, hasConnectionError, isConnecting, - sendAttemptError - } = useChatConnection({ - handleDeleteMessage, - handleDeleteUserMessages: removeMessageByUserId, - handleUserDisconnect - }); - const isLoading = isConnecting || isChannelLoading; + sendAttemptError, + deletedMessageIds, + deletedMessage, + setDeletedMessage, + messages + } = useChat(); + const isLoading = isConnecting || isChannelLoading; /** * Chat Moderation State and Actions */ const isModerator = chatUserRole === CHAT_USER_ROLE.MODERATOR; const [isChatPopupOpen, setIsChatPopupOpen] = useState(false); const [selectedMessage, setSelectedMessage] = useState({}); - const openChatPopup = useCallback((messageData) => { - setIsChatPopupOpen(true); - setSelectedMessage(messageData); - }, []); + const openChatPopup = useCallback( + (messageData) => { + setIsChatPopupOpen(true); + setSelectedMessage(messageData); + }, + [setSelectedMessage] + ); // Show moderation pill if user role is moderator useEffect(() => { @@ -131,6 +89,14 @@ const Chat = ({ shouldRunCelebration }) => { setChatContainerDimensions({ width: clientWidth, height: clientHeight }); }, []); + const handleDeleteMessage = useCallback(() => { + const { id } = selectedMessage; + actions.deleteMessage(id); + deletedMessageIds.current.push(id); + + notifySuccess($content.notifications.success.message_removed); + }, [selectedMessage, actions, deletedMessageIds, notifySuccess]); + useResizeObserver(chatSectionRef, (entry) => { if (entry) updateChatContainerDimensions(entry.target); }); @@ -141,6 +107,35 @@ const Chat = ({ shouldRunCelebration }) => { } }, [chatSectionRef, updateChatContainerDimensions]); + useEffect(() => { + if (deletedMessage && !isModerator) { + const message = messages?.find(({ id }) => id === deletedMessage); + if (message) { + const { + sender: { + attributes: { channelArn: deletedMessageOwner } + } + } = message; + if ( + extractChannelIdfromChannelArn(deletedMessageOwner.toLowerCase()) === + userData?.trackingId + ) + notifyError($content.notifications.error.your_message_was_removed); + } + setDeletedMessage(undefined); + } + }, [ + deletedMessage, + deletedMessageIds, + isModerator, + messages, + notifyError, + notifySuccess, + selectedMessage.id, + setDeletedMessage, + userData + ]); + return ( <>
{ {isChatPopupOpen && ( { const { isMobileView } = useResponsiveDevice(); const { userData } = useUser(); const { username } = userData || {}; const { openModal } = useModal(); - const { deletedMessageIds } = useChatMessages(); const popupRef = useRef(); const isOwnMessage = username === displayName; @@ -47,12 +45,6 @@ const ChatPopup = ({ onRefocus: hideChatPopup }); - const handleDeleteMessage = useCallback(() => { - deleteMessage(id); - deletedMessageIds.current.push(id); - handleClose(); - }, [deleteMessage, deletedMessageIds, handleClose, id]); - const handleBanUser = () => { handleClose(); @@ -187,7 +179,10 @@ const ChatPopup = ({ 'text-lightMode-red' ])} variant="tertiary" - onClick={handleDeleteMessage} + onClick={() => { + deleteMessage(); + handleClose(); + }} > {$content.delete_message} diff --git a/web-ui/src/pages/Channel/Chat/Composer.jsx b/web-ui/src/pages/Channel/Chat/Composer.jsx index d8b0689c..61ae364a 100644 --- a/web-ui/src/pages/Channel/Chat/Composer.jsx +++ b/web-ui/src/pages/Channel/Chat/Composer.jsx @@ -8,6 +8,7 @@ import { COMPOSER_MAX_CHARACTER_LENGTH, COMPOSER_RATE_LIMIT_BLOCK_TIME_MS } from '../../../constants'; +import { CHAT_MESSAGE_EVENT_TYPES } from '../../../constants'; import { channel as $channelContent } from '../../../content'; import { CHAT_USER_ROLE, SEND_ERRORS } from './useChatConnection/utils'; import { clsm } from '../../../utils'; @@ -19,6 +20,9 @@ import ComposerErrorMessage from './ComposerErrorMessage'; import FloatingNav from '../../../components/FloatingNav'; import Input from '../../../components/Input'; import useCurrentPage from '../../../hooks/useCurrentPage'; +import { usePoll } from '../../../contexts/StreamManagerActions/Poll'; + +const { SEND_MESSAGE } = CHAT_MESSAGE_EVENT_TYPES; const $content = $channelContent.chat; @@ -37,6 +41,7 @@ const Composer = ({ const { isViewerBanned: isLocked } = channelData || {}; const { isLandscape } = useResponsiveDevice(); const { isSessionValid } = useUser(); + const { setComposerRefState } = usePoll(); const [message, setMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [shouldShake, setShouldShake] = useState(false); // Composer has shake animated only on submit @@ -47,6 +52,13 @@ const Composer = ({ chatUserRole && [CHAT_USER_ROLE.SENDER, CHAT_USER_ROLE.MODERATOR].includes(chatUserRole); const focus = location.state?.focus; + + useEffect(() => { + if (composerFieldRef.current) { + setComposerRefState(composerFieldRef); + } + }, [composerFieldRef, setComposerRefState]); + const setSubmitErrorStates = (_errorMessage) => { setErrorMessage(`${$content.error.message_not_sent} ${_errorMessage}`); setShouldShake(true); @@ -90,7 +102,7 @@ const Composer = ({ setMessage(''); } - sendMessage(message); + sendMessage(message, { eventType: SEND_MESSAGE }); !errorMessage && setMessage(''); setShouldShake(false); } else { diff --git a/web-ui/src/pages/Channel/Chat/Messages/Messages.jsx b/web-ui/src/pages/Channel/Chat/Messages/Messages.jsx index 90940bdf..0c85de9e 100644 --- a/web-ui/src/pages/Channel/Chat/Messages/Messages.jsx +++ b/web-ui/src/pages/Channel/Chat/Messages/Messages.jsx @@ -5,20 +5,19 @@ import { channel as $channelContent } from '../../../../content'; import { clsm } from '../../../../utils'; import { getAvatarSrc } from '../../../../helpers'; import { useChannel } from '../../../../contexts/Channel'; -import { useChatMessages } from '../../../../contexts/ChatMessages'; +import { useChat } from '../../../../contexts/Chat'; import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; import ChatLine from './ChatLine'; import StickScrollButton from './StickScrollButton'; import useStickyScroll from '../../../../hooks/useStickyScroll'; import useResize from '../../../../hooks/useResize'; - const $content = $channelContent.chat; const Messages = ({ isChatPopupOpen, isModerator, openChatPopup }) => { const { channelData } = useChannel(); const { username: chatRoomOwnerUsername } = channelData || {}; const chatRef = useRef(); - const { messages, initMessages } = useChatMessages(); + const { messages, initMessages } = useChat(); const [hasInitMessages, setHasInitMessages] = useState(false); const { isSticky, scrollToBottom } = useStickyScroll(chatRef, messages); const { isMobileView, isLandscape } = useResponsiveDevice(); @@ -101,6 +100,7 @@ const Messages = ({ isChatPopupOpen, isModerator, openChatPopup }) => { id: messageId, message, avatarSrc, + isOwnMessage, ...restSenderAttributes }); diff --git a/web-ui/src/pages/Channel/Chat/Poll/AnimateReorderList.jsx b/web-ui/src/pages/Channel/Chat/Poll/AnimateReorderList.jsx new file mode 100644 index 00000000..fd644763 --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/AnimateReorderList.jsx @@ -0,0 +1,80 @@ +import React, { useState, useLayoutEffect, useEffect } from 'react'; + +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import usePrevious from '../../../../hooks/usePrevious'; +import { SHOW_POLL_RESULTS_ANIMATION_DURATION } from '../../../../constants'; + +export const calculateBoundingBoxes = (children) => { + const boundingBoxes = {}; + + React.Children.forEach(children, (child) => { + const domNode = child?.ref?.current; + const nodeBoundingBox = domNode?.getBoundingClientRect(); + + boundingBoxes[child.key] = nodeBoundingBox; + }); + + return boundingBoxes; +}; + +const AnimateReorderList = ({ children }) => { + const [boundingBox, setBoundingBox] = useState({}); + const [prevBoundingBox, setPrevBoundingBox] = useState({}); + const prevChildren = usePrevious(children); + const { showFinalResults, shouldAnimateListRef } = usePoll(); + + useLayoutEffect(() => { + const newBoundingBox = calculateBoundingBoxes(children); + setBoundingBox(newBoundingBox); + }, [children]); + + useLayoutEffect(() => { + const prevBoundingBox = calculateBoundingBoxes(prevChildren); + setPrevBoundingBox(prevBoundingBox); + }, [prevChildren]); + + useEffect(() => { + const hasPrevBoundingBox = Object.keys(prevBoundingBox).length; + + if (hasPrevBoundingBox) { + React.Children.forEach(children, (child) => { + const firstBox = prevBoundingBox[child.key]; + + const lastBox = boundingBox[child.key]; + const changeInYAxis = firstBox?.y - lastBox?.y; + + const domNode = child?.ref?.current; + + if ( + !shouldAnimateListRef?.current && + changeInYAxis && + changeInYAxis !== 0 && + showFinalResults + ) { + requestAnimationFrame(() => { + shouldAnimateListRef.current = true; + // Before the DOM paints, invert child to old position + domNode.style.transform = `translateY(${changeInYAxis}px)`; + domNode.style.transition = 'transform 0s'; + + requestAnimationFrame(() => { + // After the previous frame, remove the transistion to play the animation + domNode.style.transform = ''; + domNode.style.transition = `transform ${SHOW_POLL_RESULTS_ANIMATION_DURATION}ms`; + }); + }); + } + }); + } + }, [ + boundingBox, + prevBoundingBox, + children, + showFinalResults, + shouldAnimateListRef + ]); + + return children; +}; + +export default AnimateReorderList; diff --git a/web-ui/src/pages/Channel/Chat/Poll/AnimatedVoteItems.jsx b/web-ui/src/pages/Channel/Chat/Poll/AnimatedVoteItems.jsx new file mode 100644 index 00000000..c588a6a7 --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/AnimatedVoteItems.jsx @@ -0,0 +1,59 @@ +import { createRef } from 'react'; +import PropTypes from 'prop-types'; + +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import AnimateReorderList from './AnimateReorderList'; +import VoteItem from './VoteItem'; +import { useChannel } from '../../../../contexts/Channel'; + +const AnimatedVoteItems = ({ + textColor, + radioBoxControls, + showVotePercentage +}) => { + const { channelData } = useChannel(); + const { color } = channelData || {}; + const { votes, totalVotes, noVotesCaptured, highestCountOption } = usePoll(); + + return ( + + {votes.map(({ option, count, key }, index) => { + const isHighestCount = option === highestCountOption.option; + const percentage = + (!!count && Math.ceil((count / totalVotes) * 100)) || 0; + + return ( + + ); + })} + + ); +}; + +AnimatedVoteItems.defaultProps = { + showVotePercentage: true, + textColor: undefined, + radioBoxControls: {} +}; + +AnimatedVoteItems.propTypes = { + textColor: PropTypes.string, + showVotePercentage: PropTypes.bool, + // Viewer props + radioBoxControls: PropTypes.object +}; + +export default AnimatedVoteItems; diff --git a/web-ui/src/pages/Channel/Chat/Poll/Poll.jsx b/web-ui/src/pages/Channel/Chat/Poll/Poll.jsx new file mode 100644 index 00000000..74338e42 --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/Poll.jsx @@ -0,0 +1,49 @@ +import { useLocation } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import { clsm } from '../../../../utils'; +import StreamerPoll from './StreamerPoll'; +import ViewerPoll from './ViewerPoll'; +import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; +import { useChat } from '../../../../contexts/Chat'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; + +const Poll = ({ shouldRenderInTab }) => { + const { pathname } = useLocation(); + const { isActive } = usePoll(); + const { isModerator } = useChat(); + const { isDesktopView, isLandscape } = useResponsiveDevice(); + const isStreamManagerPage = pathname === '/manager'; + + return ( +
+ {isModerator && isStreamManagerPage && } + {!isStreamManagerPage && ( + + )} +
+ ); +}; + +Poll.defaultProps = { + shouldRenderInTab: false +}; + +Poll.propTypes = { + shouldRenderInTab: PropTypes.bool +}; + +export default Poll; diff --git a/web-ui/src/pages/Channel/Chat/Poll/PollContainer.jsx b/web-ui/src/pages/Channel/Chat/Poll/PollContainer.jsx new file mode 100644 index 00000000..8f9a39d8 --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/PollContainer.jsx @@ -0,0 +1,239 @@ +import { useEffect, useRef, forwardRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import PropTypes from 'prop-types'; + +import { clsm } from '../../../../utils'; +import { + createAnimationProps, + getDefaultBounceTransition +} from '../../../../helpers/animationPropsHelper'; +import { useChannel } from '../../../../contexts/Channel'; +import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import { useUser } from '../../../../contexts/User'; +import useDebouncedCallback from '../../../../hooks/useDebouncedCallback'; +import useResize from '../../../../hooks/useResize'; +import { useLocation } from 'react-router-dom'; + +// Static heights based on design +const COMPOSER_HEIGHT_PX = 92; +const VOTE_BUTTON_HEIGHT_PX = 60; +const PROGRESS_BAR_HEIGHT_PX = 46; +const FOOTER_HEIGHT_PX = VOTE_BUTTON_HEIGHT_PX + PROGRESS_BAR_HEIGHT_PX; +const SPACE_BETWEEN_POLL_AND_COMPOSER_PX = 20; + +const PollContainer = forwardRef(({ children }, ref) => { + const marginBotttomRef = useRef(); + const [height, setHeight] = useState(); + const fullHeightOfPoll = useRef(); + const { channelData } = useChannel(); + const { + hasScrollbar, + isVoting, + hasPollEnded, + composerRefState, + dispatchPollState, + isActive + } = usePoll(); + + const { isTouchscreenDevice, isLandscape, isDesktopView } = + useResponsiveDevice(); + const { isSessionValid, userData = {} } = useUser(); + const { pathname } = useLocation(); + const { color } = channelData || {}; + const pollComponent = ref?.current; + const composerComponent = composerRefState?.current; + + let previousHeight = window.innerHeight; + + const getScrollableContentHeight = (windowHeight, hasPollEnded, isVoting) => { + const remainingHeightToFill = + windowHeight - COMPOSER_HEIGHT_PX - SPACE_BETWEEN_POLL_AND_COMPOSER_PX; + + if (hasPollEnded) return remainingHeightToFill; + + // Logged out and poll is active + if (!userData?.username) + return remainingHeightToFill - PROGRESS_BAR_HEIGHT_PX; + + return ( + remainingHeightToFill - + (isVoting ? FOOTER_HEIGHT_PX : PROGRESS_BAR_HEIGHT_PX) + ); + }; + + useEffect(() => { + marginBotttomRef.current = + isLandscape || !isSessionValid ? 'mb-28' : 'mb-0'; + }, [isTouchscreenDevice, isLandscape, isSessionValid]); + + useEffect(() => { + // Recalculate the height of the poll if user has voted or the poll has ended + if (fullHeightOfPoll?.current && (!isVoting || hasPollEnded)) { + const scrollableContentHeight = getScrollableContentHeight( + window.innerHeight, + hasPollEnded, + isVoting + ); + + const remainingHeightToFill = + window.innerHeight - + COMPOSER_HEIGHT_PX - + SPACE_BETWEEN_POLL_AND_COMPOSER_PX; + const calculatedPollHeight = + ref.current.scrollHeight + PROGRESS_BAR_HEIGHT_PX; + + if (calculatedPollHeight > remainingHeightToFill) { + setHeight(scrollableContentHeight); + dispatchPollState({ hasScrollbar: true }); + } else { + setHeight('100%'); + dispatchPollState({ hasScrollbar: false }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasPollEnded, isVoting]); + + useResize( + useDebouncedCallback(() => { + if (pollComponent && composerComponent) { + const poll = pollComponent?.getBoundingClientRect(); + const composer = composerComponent?.getBoundingClientRect(); + const scrollableContentHeight = getScrollableContentHeight( + window.innerHeight, + hasPollEnded, + isVoting + ); + + // Mounting + if (!fullHeightOfPoll?.current) { + fullHeightOfPoll.current = ref?.current?.scrollHeight; + + const isPollComposerOverlapping = + poll.bottom > composer?.top && poll?.top < composer?.bottom; + const distanceY = Math.abs(poll?.bottom - composer?.top); + const shouldAddScrollBar = + isPollComposerOverlapping || distanceY < 20; + + if (shouldAddScrollBar) { + setHeight(scrollableContentHeight); + dispatchPollState({ hasScrollbar: true }); + } + } else { + // Resizing + const isDecreasingBrowserHeight = window.innerHeight < previousHeight; + + if (isDecreasingBrowserHeight) { + const distanceY = + poll?.bottom - + composer?.top + + (hasScrollbar ? FOOTER_HEIGHT_PX : 0); + + if (distanceY > -20) { + setHeight(scrollableContentHeight); + dispatchPollState({ hasScrollbar: true }); + } + } else { + // Increasing browser height + if (height === '100%') return; + + let calculatedPollHeight = scrollableContentHeight; + + if (!userData?.username) { + calculatedPollHeight += PROGRESS_BAR_HEIGHT_PX; + } else { + calculatedPollHeight += isVoting + ? FOOTER_HEIGHT_PX + : PROGRESS_BAR_HEIGHT_PX; + } + + const remainingHeightToFill = + window.innerHeight - + COMPOSER_HEIGHT_PX - + SPACE_BETWEEN_POLL_AND_COMPOSER_PX; + const shouldRemoveScrollbar = + (isVoting && calculatedPollHeight > fullHeightOfPoll?.current) || + (!isVoting && + ref.current.scrollHeight + PROGRESS_BAR_HEIGHT_PX < + remainingHeightToFill) || + (hasPollEnded && + ref.current.scrollHeight < remainingHeightToFill); + + if (shouldRemoveScrollbar) { + setHeight('100%'); + dispatchPollState({ hasScrollbar: false }); + } else { + setHeight(scrollableContentHeight); + dispatchPollState({ hasScrollbar: true }); + } + } + + previousHeight = window.innerHeight; + } + } + }, 200), + { shouldCallOnMount: true } + ); + const isStreamManagerPage = pathname === '/manager'; + const defaultBounceTransition = getDefaultBounceTransition(isActive); + + return ( + + +
+ {children} +
+
+
+ ); +}); + +PollContainer.propTypes = { + children: PropTypes.node.isRequired +}; + +export default PollContainer; diff --git a/web-ui/src/pages/Channel/Chat/Poll/StreamerPoll.jsx b/web-ui/src/pages/Channel/Chat/Poll/StreamerPoll.jsx new file mode 100644 index 00000000..68349fd3 --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/StreamerPoll.jsx @@ -0,0 +1,165 @@ +import { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsm } from '../../../../utils'; + +import { ChevronDown, ChevronUp } from '../../../../assets/icons'; +import { + STREAM_ACTION_NAME, + BREAKPOINTS, + PROFILE_COLORS_WITH_WHITE_TEXT +} from '../../../../constants'; +import { streamManager as $streamManagerContent } from '../../../../content'; +import { useChannel } from '../../../../contexts/Channel'; +import { useModal } from '../../../../contexts/Modal'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; +import Button from '../../../../components/Button'; +import usePrompt from '../../../../hooks/usePrompt'; +import PollContainer from './PollContainer'; +import AnimatedVoteItems from './AnimatedVoteItems'; +import { + createAnimationProps, + getDefaultBounceTransition +} from '../../../../helpers/animationPropsHelper'; + +const $content = + $streamManagerContent.stream_manager_actions[STREAM_ACTION_NAME.POLL]; + +const StreamerPoll = () => { + const pollRef = useRef(null); + const { isActive, question, dispatchPollState, isExpanded, totalVotes } = + usePoll(); + + useEffect(() => { + if (pollRef?.current) { + dispatchPollState({ pollRef: pollRef.current }); + } + }, [dispatchPollState, isExpanded, pollRef]); + + const { channelData } = useChannel(); + const { color } = channelData || {}; + const textColor = PROFILE_COLORS_WITH_WHITE_TEXT.includes(color) + ? 'white' + : 'black'; + + const { currentBreakpoint } = useResponsiveDevice(); + const { openModal } = useModal(); + const showVotePercentage = currentBreakpoint <= BREAKPOINTS.xs ? true : false; + + const { isBlocked, onCancel, onConfirm } = usePrompt(isActive); + + useEffect(() => { + if (isBlocked && isActive) { + openModal({ + content: { + confirmText: $content.leave_page, + isDestructive: true, + message:

{$content.confirm_leave_page}

+ }, + onConfirm, + onCancel + }); + } + }, [isBlocked, onCancel, onConfirm, openModal, isActive]); + + return ( + + + + +

+ {question} +

+
+ +
+

{`${$content.total_votes}: ${totalVotes.toLocaleString()}`}

+
+
+
+ ); +}; + +export default StreamerPoll; diff --git a/web-ui/src/pages/Channel/Chat/Poll/ViewerPoll.jsx b/web-ui/src/pages/Channel/Chat/Poll/ViewerPoll.jsx new file mode 100644 index 00000000..75a9a0ed --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/ViewerPoll.jsx @@ -0,0 +1,227 @@ +import { useCallback, useRef, useEffect } from 'react'; +import { AnimatePresence, motion, useAnimationControls } from 'framer-motion'; +import PropTypes from 'prop-types'; + +import { clsm } from '../../../../utils'; +import { + PROFILE_COLORS_WITH_WHITE_TEXT, + STREAM_ACTION_NAME, + CHAT_MESSAGE_EVENT_TYPES +} from '../../../../constants'; +import { createAnimationProps } from '../../../../helpers/animationPropsHelper'; +import { streamManager as $streamManagerContent } from '../../../../content'; +import { useChannel } from '../../../../contexts/Channel'; +import { useChat } from '../../../../contexts/Chat'; +import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import { useUser } from '../../../../contexts/User'; +import AnimatedVoteItems from './AnimatedVoteItems'; +import Button from '../../../../components/Button/Button'; +import ProgressBar from '../../ViewerStreamActions/ProgressBar'; +import Spinner from '../../../../components/Spinner'; +import PollContainer from './PollContainer'; + +const $content = + $streamManagerContent.stream_manager_actions[STREAM_ACTION_NAME.POLL]; + +const ViewerPoll = ({ shouldRenderInTab }) => { + const { SUBMIT_VOTE } = CHAT_MESSAGE_EVENT_TYPES; + const { + actions: { sendMessage } + } = useChat(); + const { isTouchscreenDevice } = useResponsiveDevice(); + const { + isSubmitting, + selectedOption, + startTime, + duration, + setPollRef, + dispatchPollState, + question, + showFinalResults, + hasScrollbar, + hasPollEnded, + shouldRenderVoteButton, + shouldRenderProgressbar, + isActive + } = usePoll(); + + const { channelData } = useChannel(); + const { color } = channelData || {}; + const { userData = undefined } = useUser(); + const { trackingId = undefined } = userData || {}; + const buttonDivControls = useAnimationControls(); + const radioBoxControls = useAnimationControls(); + + const textColor = PROFILE_COLORS_WITH_WHITE_TEXT.includes(color) + ? 'white' + : 'black'; + + const submitVote = useCallback(async () => { + dispatchPollState({ isSubmitting: true }); + + const result = await sendMessage(SUBMIT_VOTE, { + voter: trackingId, + eventType: SUBMIT_VOTE, + option: selectedOption, + duration: JSON.stringify(duration), + startTime: JSON.stringify(startTime) + }); + + await Promise.all([ + radioBoxControls.start({ + left: '-300px', + opacity: 0, + transition: { duration: 0.1 } + }), + buttonDivControls.start({ + height: 0, + padding: 0, + opacity: 0, + transition: { duration: 0.1 } + }) + ]); + + if (result) { + dispatchPollState({ isSubmitting: false, isVoting: false }); + } + }, [ + SUBMIT_VOTE, + buttonDivControls, + duration, + radioBoxControls, + selectedOption, + sendMessage, + startTime, + trackingId, + dispatchPollState + ]); + + const pollRef = useRef(); + + useEffect(() => { + if (pollRef?.current) { + dispatchPollState({ pollRef: pollRef.current }); + } + }, [dispatchPollState, pollRef, setPollRef]); + + const showVoteAndProgress = !hasPollEnded && !showFinalResults; + const showVoteAndProgressAsFooter = hasScrollbar && !shouldRenderInTab; + + const renderProgressBar = ( +
+ +
+ ); + + const renderVoteButton = ( + <> + {shouldRenderVoteButton && ( + + + + )} + + ); + + return ( + <> + +

+ {question} +

+ +
+ + + +
+ + {!showVoteAndProgressAsFooter && ( + <> + {renderVoteButton} + {renderProgressBar} + + )} + +
+ {showVoteAndProgress && showVoteAndProgressAsFooter && isActive && ( + <> +
+
+ {renderProgressBar} + {renderVoteButton} +
+ + )} + + ); +}; + +ViewerPoll.propTypes = { + shouldRenderInTab: PropTypes.bool.isRequired +}; + +export default ViewerPoll; diff --git a/web-ui/src/pages/Channel/Chat/Poll/VoteItem.jsx b/web-ui/src/pages/Channel/Chat/Poll/VoteItem.jsx new file mode 100644 index 00000000..3b89a3ab --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/VoteItem.jsx @@ -0,0 +1,350 @@ +import { useEffect, forwardRef } from 'react'; +import PropTypes from 'prop-types'; +import { motion } from 'framer-motion'; + +import { CheckCircle } from '../../../../assets/icons'; +import { clsm, convertConcurrentViews } from '../../../../utils'; +import { createAnimationProps } from '../../../../helpers/animationPropsHelper'; +import { STREAM_ACTION_NAME } from '../../../../constants'; +import { streamManager as $streamManagerContent } from '../../../../content'; +import Tooltip from '../../../../components/Tooltip/Tooltip'; +import { useLocation } from 'react-router-dom'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; +import { useUser } from '../../../../contexts/User'; + +const $content = + $streamManagerContent.stream_manager_actions[STREAM_ACTION_NAME.POLL]; + +const opacityAnimation = createAnimationProps({ + customVariants: { + hidden: { + opacity: 0 + }, + visible: { + opacity: 1 + } + }, + transition: { duration: 0.3 } +}); + +const VoteItem = forwardRef( + ( + { + count, + isHighestCount, + option, + percentage, + showVotePercentage, + color, + textColor, + inputAndLabelId, + radioBoxControls + }, + ref + ) => { + const { + selectedOption, + setSelectedOption, + isVoting, + showFinalResults, + noVotesCaptured, + shouldRenderRadioInput + } = usePoll(); + const hasWon = isHighestCount && showFinalResults; + const countFormatted = convertConcurrentViews(count); + const { pathname } = useLocation(); + const { isSessionValid } = useUser(); + + const voteContent = + count === 1 ? $content.vote.toLowerCase() : $content.votes; + const isStreamManagerPage = pathname === '/manager'; + const isPollPercentageVisible = + !isSessionValid || !isVoting || showFinalResults || isStreamManagerPage; + + const showCurrentVotes = isPollPercentageVisible || noVotesCaptured; + + useEffect(() => { + /** + * This code dynamically adjusts the height of certain containers based on the height + * of their child elements, specifically '.vote-option-container' and + * '.vote-option-parent-container'. If any '.vote-option-container' + * has a height between 24 and 44 pixels, it sets shouldResizeAllContainers to true, + * and the parent containers are given a height of either 58 pixels or 44 pixels + * based on the value of shouldResizeAllContainers. + */ + let shouldResizeAllContainers = false; + const listItems = document.querySelectorAll('.vote-option-container'); + const parentDivs = document.querySelectorAll( + '.vote-option-parent-container' + ); + + for (const item of listItems) { + if (item.offsetHeight > 24 && item.offsetHeight < 50) { + shouldResizeAllContainers = true; + break; + } + } + + parentDivs.forEach((item) => { + if (hasWon) return; + item.classList.add(shouldResizeAllContainers ? 'h-14' : 'h-11'); + }); + }, [option, color, hasWon]); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{ + if (!isVoting) return; + setSelectedOption(option); + }} + className={clsm([ + 'overflow-hidden', + 'vote-option-parent-container', + 'relative', + 'w-full', + `bg-poll-${color}-pollVoteBg`, + 'rounded-[100px]', + 'h-full', + hasWon && [ + 'h-20', + `bg-poll-${color}-pollVoteBg`, + 'border-2', + 'border-white', + 'mb-1.5' + ] + ])} + > + +
+
+
+ {hasWon && ( +

+ {$content.winner} +

+ )} +
+
input.radio]:top-[0px]', + 'flex', + 'relative', + 'items-center' + ])} + > + {shouldRenderRadioInput && ( + { + setSelectedOption(option); + }} + type="radio" + value={selectedOption} + /> + )} +
+ +
+
+ + {isSessionValid && selectedOption === option && !isVoting && ( + + )} + +
+ {showCurrentVotes && ( +
+ {showVotePercentage ? ( + + {`${percentage}%`} + + ) : ( +

{`${percentage}% (${countFormatted} ${voteContent})`}

+ )} +
+ )} +
+
+ ); + } +); + +VoteItem.defaultProps = { + count: 0, + isHighestCount: false, + showVotePercentage: false, + percentage: 0, + radioBoxControls: {}, + inputAndLabelId: undefined, + noVotesCaptured: false +}; + +VoteItem.propTypes = { + count: PropTypes.number, + color: PropTypes.string.isRequired, + textColor: PropTypes.string.isRequired, + isHighestCount: PropTypes.bool, + option: PropTypes.string.isRequired, + percentage: PropTypes.number, + showVotePercentage: PropTypes.bool, + inputAndLabelId: PropTypes.string, + radioBoxControls: PropTypes.object, + noVotesCaptured: PropTypes.bool +}; + +export default VoteItem; diff --git a/web-ui/src/pages/Channel/Chat/Poll/index.js b/web-ui/src/pages/Channel/Chat/Poll/index.js new file mode 100644 index 00000000..3b7fe13f --- /dev/null +++ b/web-ui/src/pages/Channel/Chat/Poll/index.js @@ -0,0 +1 @@ +export { default } from './Poll'; diff --git a/web-ui/src/pages/Channel/Chat/useChatConnection/useChatActions.js b/web-ui/src/pages/Channel/Chat/useChatConnection/useChatActions.js index 1572b7b9..54e7f240 100644 --- a/web-ui/src/pages/Channel/Chat/useChatConnection/useChatActions.js +++ b/web-ui/src/pages/Channel/Chat/useChatConnection/useChatActions.js @@ -61,7 +61,7 @@ const useChatActions = ({ }, [chatCapabilities]); const send = useCallback( - async (content) => { + async (content, attributes = {}) => { try { if (!isConnectionOpen) throw new Error( @@ -69,14 +69,14 @@ const useChatActions = ({ ); const sendRequestId = uuidv4(); - const sendRequestAttributes = {}; const sendRequest = new SendMessageRequest( content, - sendRequestAttributes, + attributes, sendRequestId ); await connection.current.sendMessage(sendRequest); + return true; } catch (error) { if (Object.values(SEND_ERRORS).indexOf(error.errorMessage) > -1) { setSendAttemptError({ @@ -86,13 +86,14 @@ const useChatActions = ({ console.error(error); } + return false; }, [connection, isConnectionOpen, setSendAttemptError] ); // Actions const sendMessage = useCallback( - (msg) => { + (msg, attr = {}) => { if ( ![CHAT_USER_ROLE.SENDER, CHAT_USER_ROLE.MODERATOR].includes( chatUserRole @@ -104,7 +105,7 @@ const useChatActions = ({ return; } - send(encode(msg)); + return send(encode(msg), attr); }, [chatUserRole, send] ); @@ -117,6 +118,7 @@ const useChatActions = ({ ); return; } + const deleteMessageRequest = new DeleteMessageRequest(messageId); connection.current.deleteMessage(deleteMessageRequest); }, diff --git a/web-ui/src/pages/Channel/Player/Controls/RenditionSetting/RenditionSettingPopup.jsx b/web-ui/src/pages/Channel/Player/Controls/RenditionSetting/RenditionSettingPopup.jsx index 54d642bf..ccbd6cb9 100644 --- a/web-ui/src/pages/Channel/Player/Controls/RenditionSetting/RenditionSettingPopup.jsx +++ b/web-ui/src/pages/Channel/Player/Controls/RenditionSetting/RenditionSettingPopup.jsx @@ -1,5 +1,5 @@ import { motion } from 'framer-motion'; -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { Close } from '../../../../../assets/icons'; @@ -9,6 +9,7 @@ import { player as $content } from '../../../../../content'; import { useResponsiveDevice } from '../../../../../contexts/ResponsiveDevice'; import Button from '../../../../../components/Button'; import useClickAway from '../../../../../hooks/useClickAway'; +import { usePlayerContext } from '../../../contexts/Player'; export const POPUP_ID = 'rendition'; @@ -22,7 +23,7 @@ const RenditionSettingPopup = ({ }) => { const [qualitiesContainerPos, setQualitiesContainerPos] = useState(null); const { isMobileView } = useResponsiveDevice(); - const qualitiesContainerRef = useRef(); + const { qualitiesContainerRef } = usePlayerContext(); const onSelectQualityHandler = useCallback( (event) => { @@ -55,7 +56,7 @@ const RenditionSettingPopup = ({ } else { setQualitiesContainerPos(null); } - }, [isOpen, qualities]); + }, [isOpen, qualities, qualitiesContainerRef]); return ( <> diff --git a/web-ui/src/pages/Channel/Player/FollowButton.jsx b/web-ui/src/pages/Channel/Player/FollowButton.jsx index 785bde10..48b1b0c9 100644 --- a/web-ui/src/pages/Channel/Player/FollowButton.jsx +++ b/web-ui/src/pages/Channel/Player/FollowButton.jsx @@ -31,7 +31,7 @@ const textAnimationProps = createAnimationProps({ transition: customSpringTransition }); -const FollowButton = ({ isExpandedView }) => { +const FollowButton = ({ isExpandedView, setFollowButtonRefState }) => { const navigate = useNavigate(); const location = useLocation(); const { notifyError } = useNotif(); @@ -87,8 +87,10 @@ const FollowButton = ({ isExpandedView }) => { (element) => { subscribeOverlayElement(element); buttonRef.current = element; + + setFollowButtonRefState(buttonRef.current); }, - [subscribeOverlayElement] + [setFollowButtonRefState, subscribeOverlayElement] ); const updateFollowingList = useCallback(async () => { @@ -285,7 +287,8 @@ FollowButton.defaultProps = { }; FollowButton.propTypes = { - isExpandedView: PropTypes.bool + isExpandedView: PropTypes.bool, + setFollowButtonRefState: PropTypes.func.isRequired }; export default FollowButton; diff --git a/web-ui/src/pages/Channel/Player/Player.jsx b/web-ui/src/pages/Channel/Player/Player.jsx index 64c2844c..d1b0f749 100644 --- a/web-ui/src/pages/Channel/Player/Player.jsx +++ b/web-ui/src/pages/Channel/Player/Player.jsx @@ -24,6 +24,7 @@ import StreamVideo from './StreamVideo'; import useFullscreen from './useFullscreen'; import usePrevious from '../../../hooks/usePrevious'; import useProfileViewPlayerAnimation from './useProfileViewPlayerAnimation'; +import { usePoll } from '../../../contexts/StreamManagerActions/Poll'; const nonDoubleClickableTags = ['img', 'h3', 'button', 'svg', 'path']; const nonDoubleClickableIds = [ @@ -68,6 +69,7 @@ const Player = ({ chatSectionRef }) => { }, setShouldKeepOverlaysVisible } = usePlayerContext(); + const { isActive: isPollActive } = usePoll(); const [isPlayerLoading, setIsPlayerLoading] = useState(isLoading); const [shouldShowStream, setShouldShowStream] = useState( isLive !== false || hasPlayedFinalBuffer === false @@ -239,7 +241,12 @@ const Player = ({ chatSectionRef }) => { ref={playerSectionRef} > - + { }} /> { + return clsm([ + 'flex', + 'items-center', + 'space-x-2', + 'shrink-0', + 'pointer-events-all', + !shouldRemoveZIndex && 'z-10' + ]); +}; -const PlayerHeader = ({ avatarSrc, color, username }) => { +const PlayerHeader = ({ avatarSrc, color, username, openPopupIds }) => { const { getProfileViewAnimationProps, headerAnimationControls, @@ -31,6 +35,78 @@ const PlayerHeader = ({ avatarSrc, color, username }) => { shouldAnimateProfileView, toggleProfileView } = useProfileViewAnimation(); + + const isRenditionSettingPopupExpanded = !!openPopupIds.find( + (openPopupId) => openPopupId === POPUP_ID + ); + const [shouldRemoveFollowButtonZIndex, setShouldRemoveFollowButtonZIndex] = + useState(false); + const [followButtonRefState, setFollowButtonRefState] = useState(); + const { qualitiesContainerRef } = usePlayerContext(); + + useResize(() => { + if ( + isRenditionSettingPopupExpanded && + followButtonRefState && + qualitiesContainerRef?.current + ) { + if ( + !shouldRemoveFollowButtonZIndex && + isElementsOverlapping( + followButtonRefState, + qualitiesContainerRef?.current + ) + ) { + setShouldRemoveFollowButtonZIndex(true); + } + + if ( + shouldRemoveFollowButtonZIndex && + !isElementsOverlapping( + followButtonRefState, + qualitiesContainerRef?.current + ) + ) { + setShouldRemoveFollowButtonZIndex(false); + } + } + }); + + const qualitiesContainerInitialHeight = + qualitiesContainerRef?.current?.clientHeight; + + // On mount + useEffect(() => { + if ( + isRenditionSettingPopupExpanded && + followButtonRefState && + qualitiesContainerRef?.current + ) { + if ( + isElementsOverlapping( + followButtonRefState, + qualitiesContainerRef?.current + ) || + qualitiesContainerRef?.current.clientHeight > + qualitiesContainerInitialHeight + ) { + setShouldRemoveFollowButtonZIndex(true); + } + } + }, [ + followButtonRefState, + qualitiesContainerRef, + isRenditionSettingPopupExpanded, + qualitiesContainerInitialHeight, + shouldRemoveFollowButtonZIndex + ]); + + useEffect(() => { + if (!isRenditionSettingPopupExpanded) { + setShouldRemoveFollowButtonZIndex(false); + } + }, [isRenditionSettingPopupExpanded]); + const { isOverlayVisible } = usePlayerContext(); const { isSessionValid } = useUser(); const { channelData: { isLive } = {} } = useChannel(); @@ -68,7 +144,7 @@ const PlayerHeader = ({ avatarSrc, color, username }) => { { - + - {currentViewerStreamActionName === STREAM_ACTION_NAME.QUIZ && + {!isPollActive && + currentViewerStreamActionName === STREAM_ACTION_NAME.QUIZ && !shouldRenderActionInTab && ( )} - {[ - STREAM_ACTION_NAME.PRODUCT, - STREAM_ACTION_NAME.AMAZON_PRODUCT - ].includes(currentViewerStreamActionName) && + {!isPollActive && + [ + STREAM_ACTION_NAME.PRODUCT, + STREAM_ACTION_NAME.AMAZON_PRODUCT + ].includes(currentViewerStreamActionName) && !shouldRenderActionInTab && ( )} - {currentViewerStreamActionName === STREAM_ACTION_NAME.NOTICE && ( - - )} + {!isPollActive && + currentViewerStreamActionName === STREAM_ACTION_NAME.NOTICE && ( + + )} ); }; PlayerViewerStreamActions.propTypes = { + isPollActive: PropTypes.bool, isPopupOpen: PropTypes.bool.isRequired, onClickPlayerHandler: PropTypes.func.isRequired, shouldShowStream: PropTypes.bool }; -PlayerViewerStreamActions.defaultProps = { shouldShowStream: false }; +PlayerViewerStreamActions.defaultProps = { + isPollActive: false, + shouldShowStream: false +}; export default PlayerViewerStreamActions; diff --git a/web-ui/src/pages/Channel/ViewerStreamActions/Product/components/ProductButtons.jsx b/web-ui/src/pages/Channel/ViewerStreamActions/Product/components/ProductButtons.jsx index 8853aad0..8767c9b8 100644 --- a/web-ui/src/pages/Channel/ViewerStreamActions/Product/components/ProductButtons.jsx +++ b/web-ui/src/pages/Channel/ViewerStreamActions/Product/components/ProductButtons.jsx @@ -26,7 +26,7 @@ const ProductButtons = forwardRef( ({ openProductDetails, variant, productUrl }, ref) => { const { channelData } = useChannel(); - const { color = 'default' } = channelData; + const color = channelData?.color || 'default'; const isModal = variant !== 'popup'; const isAmazonProduct = productUrl !== ''; diff --git a/web-ui/src/pages/Channel/contexts/Player.js b/web-ui/src/pages/Channel/contexts/Player.js index 64bceedc..e9ae2656 100644 --- a/web-ui/src/pages/Channel/contexts/Player.js +++ b/web-ui/src/pages/Channel/contexts/Player.js @@ -73,6 +73,7 @@ export const Provider = ({ children }) => { const { isTouchscreenDevice } = useResponsiveDevice(); const targetElements = useRef(new Map()); const timeoutId = useRef(null); + const qualitiesContainerRef = useRef(null); const closeOverlay = useCallback(() => { if (shouldKeepOverlaysVisible) return; @@ -237,7 +238,8 @@ export const Provider = ({ children }) => { player, setShouldKeepOverlaysVisible, stopPropagAndResetTimeout, - subscribeOverlayElement + subscribeOverlayElement, + qualitiesContainerRef }), [ hasError, diff --git a/web-ui/src/pages/Channel/index.js b/web-ui/src/pages/Channel/index.js index 4a959f84..312dcad7 100644 --- a/web-ui/src/pages/Channel/index.js +++ b/web-ui/src/pages/Channel/index.js @@ -1,13 +1,16 @@ import { Provider as ChannelViewProvider } from './contexts/ChannelView'; import { Provider as ProfileViewAnimationProvider } from './contexts/ProfileViewAnimation'; +import { Provider as PollProvider } from '../../contexts/StreamManagerActions/Poll'; import ChannelPage from './Channel'; export default function Channel() { return ( - - - + + + + + ); } diff --git a/web-ui/src/pages/StreamManager/StreamManager.jsx b/web-ui/src/pages/StreamManager/StreamManager.jsx index 47dbb0be..fb59658a 100644 --- a/web-ui/src/pages/StreamManager/StreamManager.jsx +++ b/web-ui/src/pages/StreamManager/StreamManager.jsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import { clsm } from '../../utils'; import { Provider as NotificationProvider } from '../../contexts/Notification'; +import { Provider as PollProvider } from '../../contexts/StreamManagerActions/Poll'; +import { Provider as ChatProvider } from '../../contexts/Chat'; import { Provider as StreamManagerActionsProvider } from '../../contexts/StreamManagerActions'; import { Provider as StreamManagerWebBroadcastProvider } from '../../contexts/Broadcast'; import { useRef } from 'react'; @@ -54,21 +56,25 @@ const StreamManager = () => { )} > - - - - - - - - + + + + + + + + + + + +
); }; diff --git a/web-ui/src/pages/StreamManager/StreamManagerControlCenter.jsx b/web-ui/src/pages/StreamManager/StreamManagerControlCenter.jsx index 553e0a12..ff19af29 100644 --- a/web-ui/src/pages/StreamManager/StreamManagerControlCenter.jsx +++ b/web-ui/src/pages/StreamManager/StreamManagerControlCenter.jsx @@ -2,10 +2,8 @@ import PropTypes from 'prop-types'; import { useEffect, useRef, useState, forwardRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { - STREAM_MANAGER_DEFAULT_TAB, - STREAM_MANAGER_WEB_BROADCAST_TAB -} from '../../constants'; +import { STREAM_MANAGER_DEFAULT_TAB } from '../../constants'; +import { Provider as NotificationProvider } from '../../contexts/Notification'; import { clsm } from '../../utils'; import { streamManager as $content } from '../../content'; import { @@ -51,17 +49,11 @@ const StreamManagerControlCenter = forwardRef( if (isDesktopView) { setSelectedTabIndex(STREAM_MANAGER_DEFAULT_TAB); - } else { - if (isBroadcasting) { - setSelectedTabIndex(STREAM_MANAGER_WEB_BROADCAST_TAB); - } } }, [isDesktopView, resetPreview, state, isBroadcasting]); return ( <> - -
div>button]:h-9' ])} > + + {!isDesktopView && (
- + + +
{!isDesktopView && ( diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionButton.jsx b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionButton.jsx index c5da62ec..0d8fd00f 100644 --- a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionButton.jsx +++ b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionButton.jsx @@ -9,6 +9,7 @@ import { useResponsiveDevice } from '../../../../contexts/ResponsiveDevice'; import { useStreamManagerActions } from '../../../../contexts/StreamManagerActions'; import { useUser } from '../../../../contexts/User'; import useCountdown from '../../../../hooks/useCountdown'; +import { usePoll } from '../../../../contexts/StreamManagerActions/Poll'; const $content = $streamManagerContent.stream_manager_actions; const PROGRESS_PIE_RADIUS = 14; @@ -23,19 +24,41 @@ const DEFAULT_TRANSITION_CLASSES = [ const StreamManagerActionButton = forwardRef( ({ ariaLabel, icon, label, name, onClick }, ref) => { const Icon = icon; + const { hasPollEnded, savedPollData } = usePoll(); const { hasFetchedInitialUserData, userData } = useUser(); const { color = 'default' } = userData || {}; const { currentBreakpoint } = useResponsiveDevice(); const isSmallBreakpoint = currentBreakpoint < BREAKPOINTS.sm; - const { activeStreamManagerActionData, stopStreamAction } = - useStreamManagerActions(); const { - duration: activeStreamManagerActionDuration, - name: activeStreamManagerActionName, - expiry: activeStreamManagerActionExpiry - } = activeStreamManagerActionData || {}; + activeStreamManagerActionData, + stopStreamAction, + endPollOnExpiry, + cancelActivePoll + } = useStreamManagerActions(); + + let activeStreamManagerActionDuration; + let activeStreamManagerActionName; + let activeStreamManagerActionExpiry; + + if (name !== STREAM_ACTION_NAME.POLL) { + const { duration, name, expiry } = activeStreamManagerActionData || {}; + + activeStreamManagerActionDuration = duration; + activeStreamManagerActionName = name; + activeStreamManagerActionExpiry = expiry; + } else { + const { duration, name, expiry } = + (savedPollData?.isActive && savedPollData) || {}; + + activeStreamManagerActionDuration = duration; + activeStreamManagerActionName = name; + activeStreamManagerActionExpiry = expiry; + } + const isActive = name === activeStreamManagerActionName; const isPerpetual = isActive && !activeStreamManagerActionExpiry; + const isCountingDown = isActive && !isPerpetual; + const [textFormattedTimeLeft, currentProgress] = useCountdown({ expiry: activeStreamManagerActionExpiry, formatter: (timeLeft) => [ @@ -43,23 +66,33 @@ const StreamManagerActionButton = forwardRef( (timeLeft / (activeStreamManagerActionDuration * 1000)) * STROKE_DASHARRAY_MAX ], - isEnabled: isActive && !isPerpetual, - onExpiry: stopStreamAction + isEnabled: isCountingDown, + onExpiry: + name === STREAM_ACTION_NAME.POLL && !hasPollEnded + ? endPollOnExpiry + : stopStreamAction }); const handleClick = () => { - if (isActive) stopStreamAction(); + if (isActive) + name === STREAM_ACTION_NAME.POLL + ? cancelActivePoll() + : stopStreamAction(); else onClick(); }; - const isCountingDown = isActive && !isPerpetual; const currentLabel = ( <> - {isActive ? label.active : label.default} + {!isActive && label.default} + {isActive && !hasPollEnded && label.active} + {isActive && hasPollEnded && $content.poll.showing_results}
- {name !== STREAM_ACTION_NAME.AMAZON_PRODUCT && `a ${name}`} + {![STREAM_ACTION_NAME.AMAZON_PRODUCT, STREAM_ACTION_NAME.POLL].includes( + name + ) && `a ${name}`} ); + let statusLabel = isActive && (isPerpetual ? $content.on : textFormattedTimeLeft); diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/Quiz.jsx b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/Quiz.jsx deleted file mode 100644 index 522c3536..00000000 --- a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/Quiz.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useStreamManagerActions } from '../../../../../contexts/StreamManagerActions'; -import { streamManager as $streamManagerContent } from '../../../../../content'; -import { - QUIZ_DATA_KEYS, - STREAM_ACTION_NAME, - STREAM_MANAGER_ACTION_LIMITS -} from '../../../../../constants'; -import Input from './formElements/Input'; -import RadioGroup from './formElements/RadioGroup'; -import RangeSelector from './formElements/RangeSelector'; - -const $content = $streamManagerContent.stream_manager_actions.quiz; -const LIMITS = STREAM_MANAGER_ACTION_LIMITS[STREAM_ACTION_NAME.QUIZ]; - -const Quiz = () => { - const { - currentStreamManagerActionErrors, - getStreamManagerActionData, - updateStreamManagerActionData - } = useStreamManagerActions(); - const { question, answers, correctAnswerIndex, duration } = - getStreamManagerActionData(STREAM_ACTION_NAME.QUIZ); - - const updateStreamManagerActionQuizData = (data) => { - updateStreamManagerActionData({ - dataOrFn: data, - actionName: STREAM_ACTION_NAME.QUIZ - }); - }; - - return ( - <> - - - - - ); -}; - -export default Quiz; diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/QuizOrPollQuestionsComponent.jsx b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/QuizOrPollQuestionsComponent.jsx new file mode 100644 index 00000000..77691a72 --- /dev/null +++ b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/QuizOrPollQuestionsComponent.jsx @@ -0,0 +1,155 @@ +import { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { useStreamManagerActions } from '../../../../../contexts/StreamManagerActions'; +import { streamManager as $streamManagerContent } from '../../../../../content'; +import { + QUIZ_DATA_KEYS, + POLL_DATA_KEYS, + STREAM_ACTION_NAME, + STREAM_MANAGER_ACTION_LIMITS +} from '../../../../../constants'; +import Input from './formElements/Input'; +import RadioTextGroup from './formElements/RadioTextGroup/RadioTextGroup'; +import RangeSelector from './formElements/RangeSelector'; + +const $content = $streamManagerContent.stream_manager_actions.quiz; + +const QuizOrPollQuestionsComponent = ({ formType }) => { + const { + currentStreamManagerActionErrors, + getStreamManagerActionData, + updateStreamManagerActionData + } = useStreamManagerActions(); + const { + question, + answers, + correctAnswerIndex = undefined, + duration + } = getStreamManagerActionData(formType); + + const LIMITS = useMemo( + () => STREAM_MANAGER_ACTION_LIMITS[formType], + [formType] + ); + const contentMapper = useMemo( + () => ({ + [STREAM_ACTION_NAME.QUIZ]: { + content: $streamManagerContent.stream_manager_actions.quiz, + dataKey: QUIZ_DATA_KEYS.QUESTION, + rangeSelector: { + label: $streamManagerContent.stream_manager_actions.quiz.duration, + dataKey: QUIZ_DATA_KEYS.DURATION, + min: LIMITS[QUIZ_DATA_KEYS.DURATION].min, + max: LIMITS[QUIZ_DATA_KEYS.DURATION].max + }, + inputGroup: { + label: $streamManagerContent.stream_manager_actions.quiz.answers, + type: 'radio', + dataKey: QUIZ_DATA_KEYS.ANSWERS, + min: LIMITS[QUIZ_DATA_KEYS.ANSWERS].min, + max: LIMITS[QUIZ_DATA_KEYS.ANSWERS].max + } + }, + [STREAM_ACTION_NAME.POLL]: { + content: $streamManagerContent.stream_manager_actions.poll, + dataKey: POLL_DATA_KEYS.QUESTION, + rangeSelector: { + label: $streamManagerContent.stream_manager_actions.poll.duration, + dataKey: POLL_DATA_KEYS.DURATION, + min: LIMITS[POLL_DATA_KEYS.DURATION].min, + max: LIMITS[POLL_DATA_KEYS.DURATION].max + }, + inputGroup: { + label: $streamManagerContent.stream_manager_actions.poll.answers, + type: 'text', + dataKey: POLL_DATA_KEYS.ANSWERS, + min: LIMITS[POLL_DATA_KEYS.ANSWERS].min, + max: LIMITS[POLL_DATA_KEYS.ANSWERS].max + } + } + }), + [LIMITS] + ); + + const updateStreamManagerActionQuizPollData = (data) => { + updateStreamManagerActionData({ + dataOrFn: data, + actionName: formType + }); + }; + + const radioGroupSelectedAnswerProps = + formType === STREAM_ACTION_NAME.QUIZ + ? { + selectedDataKey: QUIZ_DATA_KEYS.CORRECT_ANSWER_INDEX, + selectedOptionIndex: correctAnswerIndex + } + : {}; + + const { + content: { + question: questionLabel, + answers_input_name_attribute: streamManagerActionFormAnswers, + question_input_name_attribute: streamManagerActionFormQuestion, + duration_input_name_attribute: streamManagerActionFormDuration + }, + dataKey, + rangeSelector: { + label: rangeSelectorLabel, + dataKey: rangeSelectorDataKey, + min: rangeSelectorMin, + max: rangeSelectorMax + }, + inputGroup: { + type: inputGroupInputType, + dataKey: inputGroupDataKey, + min: inputGroupMin, + max: inputGroupMax + } + } = contentMapper[formType]; + + return ( + <> + + + + + ); +}; + +QuizOrPollQuestionsComponent.propTypes = { + formType: PropTypes.string.isRequired +}; + +export default QuizOrPollQuestionsComponent; diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioTextInput/RadioTextInput.css b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioTextInput/RadioTextInput.css deleted file mode 100644 index bdaf5b78..00000000 --- a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioTextInput/RadioTextInput.css +++ /dev/null @@ -1,159 +0,0 @@ -.radio[type='radio'] { - @apply appearance-none inline-block cursor-pointer leading-4 outline-none align-top absolute left-0 w-5 h-5 top-[22px]; -} - -.radio[type='radio']:before, -.radio[type='radio']:after { - @apply absolute left-0 top-0 w-5 h-5 rounded-full -translate-y-1/2 content-['']; -} - -.radio[type='radio']:before { - @apply shadow-focus shadow-white; -} - -.radio[type='radio'].error:before { - @apply shadow-focus shadow-darkMode-red; -} - -.radio[type='radio']:hover:before { - --color-one: hsla(var(--base-color-white), 100%, 0.3); - --color-two: hsla(var(--base-color-white), 100%, 1); - --color-three: hsla(var(--base-color-medium-gray), 18%, 0.7); - - animation-duration: 0.2s; - animation-name: expand-radio; - animation-iteration-count: 1; - animation-direction: normal; - animation-timing-function: ease-in-out; - - box-shadow: 0 0 0 8px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); -} - -.radio[type='radio']:focus-visible:before { - --color-one: hsla(var(--base-color-white), 100%, 0.6); - --color-two: hsla(var(--base-color-white), 100%, 1); - --color-three: hsla(var(--base-color-medium-gray), 18%, 0.4); - - animation-duration: 0.2s; - animation-name: expand-radio; - animation-iteration-count: 1; - animation-direction: Normal; - animation-timing-function: ease-in; - - box-shadow: 0 0 0 8px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); -} - -.radio[type='radio']:checked:before { - --color-one: hsla(var(--base-color-blue), 63%, 1); - --color-two: hsla(var(--base-color-medium-gray), 18%, 1); - - box-shadow: inset 0 0 0 2px var(--color-one), inset 0 0 0 5px var(--color-two), - inset 0 0 0 10px var(--color-one); -} - -.radio[type='radio'].withGrayBg:checked:before { - --color-one: hsla(var(--base-color-blue), 63%, 1); - --color-two: hsla(var(--base-color-white), 27%, 1); -} - -.radio[type='radio']:checked:hover:before { - --color-one: hsla(var(--base-color-blue), 63%, 0.3); - --color-two: hsla(var(--base-color-blue), 63%, 1); - --color-three: hsla(var(--base-color-medium-gray), 18%, 0.7); - - animation-duration: 0.2s; - animation-name: expand-radio; - animation-iteration-count: 1; - animation-direction: Normal; - animation-timing-function: ease-in; - - box-shadow: 0 0 0 8px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); -} - -.radio[type='radio']:checked:focus-visible:before { - --color-one: hsla(var(--base-color-blue), 63%, 0.6); - --color-two: hsla(var(--base-color-blue), 63%, 1); - --color-three: hsla(var(--base-color-medium-gray), 18%, 0.4); - - animation-duration: 0.2s; - animation-name: expand-radio; - animation-iteration-count: 1; - animation-direction: Normal; - animation-timing-function: ease-in; - - box-shadow: 0 0 0 8px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); -} - -@media (prefers-color-scheme: light) { - .radio[type='radio']:before { - @apply shadow-black; - } - - .radio[type='radio'].error:before { - @apply shadow-lightMode-red; - } - - .radio[type='radio']:hover:before { - --color-one: hsla(var(--base-color-white), 0%, 0.3); - --color-two: hsla(var(--base-color-white), 0%, 1); - --color-three: hsla(var(--base-color-white), 100%, 0.7); - } - - .radio[type='radio']:focus-visible:before { - --color-one: hsla(var(--base-color-white), 0%, 0.6); - --color-two: hsla(var(--base-color-white), 0%, 1); - --color-three: hsla(var(--base-color-white), 100%, 0.4); - } - - .radio[type='radio']:checked:before { - --color-one: hsla(var(--base-color-blue), 63%, 1); - --color-two: hsla(var(--base-color-white), 100%, 1); - } - - .radio[type='radio'].withGrayBg:checked:before { - --color-one: hsla(var(--base-color-blue), 63%, 1); - --color-two: hsla(var(--base-color-white), 91%, 1); - } - - .radio[type='radio']:checked:hover:before { - --color-one: hsla(var(--base-color-blue), 63%, 0.3); - --color-two: hsla(var(--base-color-blue), 63%, 1); - --color-three: hsla(var(--base-color-white), 100%, 0.7); - } - - .radio[type='radio']:checked:focus-visible:before { - --color-one: hsla(var(--base-color-blue), 63%, 0.6); - --color-two: hsla(var(--base-color-blue), 63%, 1); - --color-three: hsla(var(--base-color-white), 100%, 0.4); - } -} - -@keyframes expand-radio { - 0% { - @apply shadow-focus shadow-white; - } - - 25% { - box-shadow: 0 0 0 3px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); - } - - 55% { - box-shadow: 0 0 0 5px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); - } - - 85% { - box-shadow: 0 0 0 7px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); - } - - 100% { - box-shadow: 0 0 0 8px var(--color-one), inset 0 0 0 2px var(--color-two), - inset 0 0 0 5px var(--color-three), inset 0 0 0 10px var(--color-two); - } -} diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/index.js b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/index.js deleted file mode 100644 index 43f73d54..00000000 --- a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './RadioGroup'; diff --git a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioGroup.jsx b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioTextGroup/RadioTextGroup.jsx similarity index 82% rename from web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioGroup.jsx rename to web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioTextGroup/RadioTextGroup.jsx index 5b001f86..33d07848 100644 --- a/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioGroup/RadioGroup.jsx +++ b/web-ui/src/pages/StreamManager/streamManagerCards/StreamManagerActions/StreamManagerActionForms/formElements/RadioTextGroup/RadioTextGroup.jsx @@ -11,7 +11,7 @@ import usePrevious from '../../../../../../../hooks/usePrevious'; const $content = $streamManagerContent.stream_manager_actions; -const StreamManagerActionRadioGroup = ({ +const StreamManagerActionRadioTextGroup = ({ addOptionButtonText, dataKey, label, @@ -23,9 +23,11 @@ const StreamManagerActionRadioGroup = ({ placeholder, selectedDataKey, selectedOptionIndex, - updateData + updateData, + inputType, + formType }) => { - const radioInputRefs = useRef([]); + const radioTextInputRef = useRef([]); const addButtonRef = useRef(); const shouldShowDeleteOptionButton = options.length > minOptions; const shouldShowAddOptionButton = options.length < maxOptions; @@ -50,7 +52,7 @@ const StreamManagerActionRadioGroup = ({ useEffect(() => { // Focus on newly added option input field only if any option field is already in focus if (options.length > previousOptionLength) { - radioInputRefs.current[previousOptionLength].focus(); + radioTextInputRef.current[previousOptionLength].focus(); } }, [previousOptionLength, options.length]); @@ -65,6 +67,8 @@ const StreamManagerActionRadioGroup = ({ }; const handleSelectOption = ({ target }) => { + if (!selectedDataKey) return; + updateData({ [selectedDataKey]: parseInt(target.value) }); @@ -87,10 +91,10 @@ const StreamManagerActionRadioGroup = ({ updateData({ [dataKey]: options.filter((_, i) => i !== index), - [selectedDataKey]: newSelectedOptionIndex + ...(selectedDataKey && { [selectedDataKey]: newSelectedOptionIndex }) }); if (hasFocusOnInput) - radioInputRefs.current[index === 0 ? 0 : index - 1].focus(); + radioTextInputRef.current[index === 0 ? 0 : index - 1].focus(); }; return ( @@ -99,7 +103,9 @@ const StreamManagerActionRadioGroup = ({
{options.map((_, index) => ( (radioInputRefs.current[index] = el)} + formType={formType} + inputType={inputType} + ref={(el) => (radioTextInputRef.current[index] = el)} key={index} name={name} onChange={handleOptionTextChange} @@ -117,7 +123,7 @@ const StreamManagerActionRadioGroup = ({ hasRadioError={!hasSelection} /> ))} - {!hasSelection && ( + {selectedDataKey && !hasSelection && ( )}
{ onClick={save} variant={prefersDarkColorScheme ? 'secondary' : 'tertiary'} > - {$content.save} + {$content.stream_manager_actions.save} )}