diff --git a/packages/hms-video-store/src/index.ts b/packages/hms-video-store/src/index.ts index 2b1c4f816d..7e3783a783 100644 --- a/packages/hms-video-store/src/index.ts +++ b/packages/hms-video-store/src/index.ts @@ -53,6 +53,7 @@ export type { HMSQuizLeaderboardResponse, HMSQuizLeaderboardSummary, HMSTranscriptionInfo, + HMSICEServer, } from './internal'; export { EventBus } from './events/EventBus'; diff --git a/packages/hms-video-store/src/interfaces/config.ts b/packages/hms-video-store/src/interfaces/config.ts index 6a2f33e370..6246882735 100644 --- a/packages/hms-video-store/src/interfaces/config.ts +++ b/packages/hms-video-store/src/interfaces/config.ts @@ -5,6 +5,13 @@ import InitialSettings from './settings'; * @link https://docs.100ms.live/javascript/v2/features/preview * @link https://docs.100ms.live/javascript/v2/features/join */ + +export type HMSICEServer = { + urls: string[]; + userName?: string; + password?: string; +}; + export interface HMSConfig { /** * the name of the peer, can be later accessed via peer.name and can also be changed mid call. @@ -53,10 +60,14 @@ export interface HMSConfig { */ autoManageVideo?: boolean; /** - * if this flag is enabled, wake lock will be acquired automatically(if supported) when joining the room, so the device + * if this flag is enabled, wake lock will be acquired automatically (if supported) when joining the room, so the device * will be kept awake. */ autoManageWakeLock?: boolean; + /** + * use custom STUN/TURN servers for media connection (advanced) + */ + iceServers?: HMSICEServer[]; } export interface HMSMidCallPreviewConfig { diff --git a/packages/hms-video-store/src/sdk/index.ts b/packages/hms-video-store/src/sdk/index.ts index a89e83fa72..19d0ef83af 100644 --- a/packages/hms-video-store/src/sdk/index.ts +++ b/packages/hms-video-store/src/sdk/index.ts @@ -424,6 +424,7 @@ export class HMSSdk implements HMSInterface { this.localPeer!.peerId, { name: config.userName, metaData: config.metaData || '' }, config.autoVideoSubscribe, + config.iceServers, ) .then((initConfig: InitConfig | void) => { initSuccessful = true; @@ -565,6 +566,7 @@ export class HMSSdk implements HMSInterface { { name: config.userName, metaData: config.metaData! }, config.initEndpoint!, config.autoVideoSubscribe, + config.iceServers, ); HMSLogger.d(this.TAG, `✅ Joined room ${roomId}`); this.analyticsTimer.start(TimedEvent.PEER_LIST); diff --git a/packages/hms-video-store/src/signal/init/index.ts b/packages/hms-video-store/src/signal/init/index.ts index e48260e498..0d2d44612d 100644 --- a/packages/hms-video-store/src/signal/init/index.ts +++ b/packages/hms-video-store/src/signal/init/index.ts @@ -1,6 +1,8 @@ import { InitConfig } from './models'; import { ErrorFactory } from '../../error/ErrorFactory'; import { HMSAction } from '../../error/HMSAction'; +import { HMSICEServer } from '../../interfaces'; +import { transformIceServerConfig } from '../../utils/ice-server-config'; import HMSLogger from '../../utils/logger'; const TAG = '[InitService]'; @@ -26,26 +28,26 @@ export default class InitService { userAgent, initEndpoint = 'https://prod-init.100ms.live', region = '', + iceServers, }: { token: string; peerId: string; userAgent: string; initEndpoint?: string; region?: string; + iceServers?: HMSICEServer[]; }): Promise { HMSLogger.d(TAG, `fetchInitConfig: initEndpoint=${initEndpoint} token=${token} peerId=${peerId} region=${region} `); const url = getUrl(initEndpoint, peerId, userAgent, region); try { const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${token}` }, }); try { const config = await response.clone().json(); this.handleError(response, config); HMSLogger.d(TAG, `config is ${JSON.stringify(config, null, 2)}`); - return transformInitConfig(config); + return transformInitConfig(config, iceServers); } catch (err) { const text = await response.text(); HMSLogger.e(TAG, 'json error', (err as Error).message, text); @@ -78,9 +80,12 @@ export function getUrl(endpoint: string, peerId: string, userAgent: string, regi } } -export function transformInitConfig(config: any): InitConfig { +export function transformInitConfig(config: any, iceServers?: HMSICEServer[]): InitConfig { return { ...config, - rtcConfiguration: { ...config.rtcConfiguration, iceServers: config.rtcConfiguration?.ice_servers }, + rtcConfiguration: { + ...config.rtcConfiguration, + iceServers: transformIceServerConfig(config.rtcConfiguration?.ice_servers, iceServers), + }, }; } diff --git a/packages/hms-video-store/src/transport/index.ts b/packages/hms-video-store/src/transport/index.ts index 92b8e581d1..8df8228fde 100644 --- a/packages/hms-video-store/src/transport/index.ts +++ b/packages/hms-video-store/src/transport/index.ts @@ -23,7 +23,7 @@ import { ErrorFactory } from '../error/ErrorFactory'; import { HMSAction } from '../error/HMSAction'; import { HMSException } from '../error/HMSException'; import { EventBus } from '../events/EventBus'; -import { HMSRole } from '../interfaces'; +import { HMSICEServer, HMSRole } from '../interfaces'; import { HMSLocalStream } from '../media/streams/HMSLocalStream'; import { HMSLocalTrack, HMSLocalVideoTrack, HMSTrack } from '../media/tracks'; import { TrackState } from '../notification-manager'; @@ -397,8 +397,9 @@ export default class HMSTransport { peerId: string, customData: { name: string; metaData: string }, autoSubscribeVideo = false, + iceServers?: HMSICEServer[], ): Promise { - const initConfig = await this.connect(token, endpoint, peerId, customData, autoSubscribeVideo); + const initConfig = await this.connect(token, endpoint, peerId, customData, autoSubscribeVideo, iceServers); this.state = TransportState.Preview; this.observer.onStateChange(this.state); return initConfig; @@ -410,11 +411,12 @@ export default class HMSTransport { customData: { name: string; metaData: string }, initEndpoint: string, autoSubscribeVideo = false, + iceServers?: HMSICEServer[], ): Promise { HMSLogger.d(TAG, 'join: started ⏰'); try { if (!this.signal.isConnected || !this.initConfig) { - await this.connect(authToken, initEndpoint, peerId, customData, autoSubscribeVideo); + await this.connect(authToken, initEndpoint, peerId, customData, autoSubscribeVideo, iceServers); } this.validateNotDisconnected('connect'); @@ -447,6 +449,7 @@ export default class HMSTransport { peerId: string, customData: { name: string; metaData: string }, autoSubscribeVideo = false, + iceServers?: HMSICEServer[], ): Promise { this.setTransportStateForConnect(); this.joinParameters = new JoinParameters( @@ -456,9 +459,10 @@ export default class HMSTransport { customData.metaData, endpoint, autoSubscribeVideo, + iceServers, ); try { - const response = await this.internalConnect(token, endpoint, peerId); + const response = await this.internalConnect(token, endpoint, peerId, iceServers); return response; } catch (error) { const shouldRetry = @@ -474,7 +478,7 @@ export default class HMSTransport { if (shouldRetry) { const task = async () => { - await this.internalConnect(token, endpoint, peerId); + await this.internalConnect(token, endpoint, peerId, iceServers); return Boolean(this.initConfig && this.initConfig.endpoint); }; @@ -898,7 +902,7 @@ export default class HMSTransport { } } - private async internalConnect(token: string, initEndpoint: string, peerId: string) { + private async internalConnect(token: string, initEndpoint: string, peerId: string, iceServers?: HMSICEServer[]) { HMSLogger.d(TAG, 'connect: started ⏰'); const connectRequestedAt = new Date(); try { @@ -908,6 +912,7 @@ export default class HMSTransport { peerId, userAgent: this.store.getUserAgent(), initEndpoint, + iceServers, }); const room = this.store.getRoom(); if (room) { @@ -1093,6 +1098,7 @@ export default class HMSTransport { this.joinParameters!.authToken, this.joinParameters!.endpoint, this.joinParameters!.peerId, + this.joinParameters!.iceServers, ); } diff --git a/packages/hms-video-store/src/transport/models/JoinParameters.ts b/packages/hms-video-store/src/transport/models/JoinParameters.ts index b60829abc7..4e71d97146 100644 --- a/packages/hms-video-store/src/transport/models/JoinParameters.ts +++ b/packages/hms-video-store/src/transport/models/JoinParameters.ts @@ -1,3 +1,5 @@ +import { HMSICEServer } from '../../interfaces'; + export class JoinParameters { constructor( public authToken: string, @@ -6,5 +8,6 @@ export class JoinParameters { public data: string = '', public endpoint: string = 'https://prod-init.100ms.live/init', public autoSubscribeVideo: boolean = false, + public iceServers?: HMSICEServer[], ) {} } diff --git a/packages/hms-video-store/src/utils/ice-server-config.ts b/packages/hms-video-store/src/utils/ice-server-config.ts new file mode 100644 index 0000000000..4af82722f1 --- /dev/null +++ b/packages/hms-video-store/src/utils/ice-server-config.ts @@ -0,0 +1,11 @@ +import { HMSICEServer } from '../interfaces'; + +export const transformIceServerConfig = (defaultConfig?: RTCIceServer[], iceServers?: HMSICEServer[]) => { + if (!iceServers || iceServers.length === 0) { + return defaultConfig; + } + const transformedIceServers = iceServers.map(server => { + return { urls: server.urls, credentialType: 'password', credential: server.password, username: server.userName }; + }); + return transformedIceServers; +}; diff --git a/packages/hms-whiteboard/src/hooks/StoreClient.ts b/packages/hms-whiteboard/src/hooks/StoreClient.ts index 2ced592e6f..dd4f4d8a90 100644 --- a/packages/hms-whiteboard/src/hooks/StoreClient.ts +++ b/packages/hms-whiteboard/src/hooks/StoreClient.ts @@ -1,6 +1,7 @@ import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'; import { Value_Type } from '../grpc/sessionstore'; import { StoreClient } from '../grpc/sessionstore.client'; +import { OPEN_WAIT_TIMEOUT } from '../utils'; interface OpenCallbacks { handleOpen: (values: T[]) => void; @@ -31,28 +32,23 @@ export class SessionStore { }, { abort: this.abortController.signal }, ); - /** - * on open, get key count to call handleOpen with the pre-existing values from the store - * retry if getKeysCount is called before open call is completed - */ - const keyCount = await this.retryForOpen(this.getKeysCount.bind(this)); const initialValues: T[] = []; + let initialised = false; - if (!keyCount) { - handleOpen([]); - } + // on open, wait to call handleOpen with the pre-existing values from the store + setTimeout(() => { + handleOpen(initialValues); + initialised = true; + }, OPEN_WAIT_TIMEOUT); call.responses.onMessage(message => { if (message.value) { if (message.value?.data.oneofKind === 'str') { const record = JSON.parse(message.value.data.str) as T; - if (initialValues.length === keyCount) { + if (initialised) { handleChange(message.key, record); } else { initialValues.push(record); - if (initialValues.length === keyCount) { - handleOpen(initialValues); - } } } } else { @@ -103,16 +99,4 @@ export class SessionStore { delete(key: string) { return this.storeClient.delete({ key }); } - - private async retryForOpen(fn: () => Promise, retries = 3): Promise { - try { - return await fn(); - } catch (error) { - const shouldRetry = (error as Error).message.includes('peer not found') && retries > 0; - if (!shouldRetry) { - return Promise.reject(error); - } - return await this.retryForOpen(fn, retries - 1); - } - } } diff --git a/packages/hms-whiteboard/src/hooks/useCollaboration.ts b/packages/hms-whiteboard/src/hooks/useCollaboration.ts index 7cbb8205ee..0f19442285 100644 --- a/packages/hms-whiteboard/src/hooks/useCollaboration.ts +++ b/packages/hms-whiteboard/src/hooks/useCollaboration.ts @@ -63,37 +63,14 @@ export function useCollaboration({ }, []); const sessionStore = useSessionStore({ token, endpoint, handleError }); + const permissions = useSetEditorPermissions({ token, editor, zoomToContent, handleError }); - useSetEditorPermissions({ token, editor, zoomToContent, handleError }); - - useEffect(() => { - if (!sessionStore) return; - - setStoreWithStatus({ status: 'loading' }); - - const unsubs: (() => void)[] = []; - - // 1. - // Connect store to yjs store and vis versa, for both the document and awareness - - /* -------------------- Document -------------------- */ - - const handleChange = (key: string, value?: TLRecord) => { - // put / remove the records in the store - store.mergeRemoteChanges(() => { - if (!value) { - return store.remove([key as TLRecord['id']]); - } - if (key === CURRENT_PAGE_KEY) { - setCurrentPage(value as TLPage); - } else { - store.put([value]); - } - }); - }; + const handleOpen = useCallback( + (initialRecords: TLRecord[]) => { + if (!sessionStore) { + return; + } - const handleOpen = (initialRecords: TLRecord[]) => { - // 2. // Initialize the tldraw store with the session store server records—or, if the session store // is empty, initialize the session store server with the default tldraw store records. const shouldUseServerRecords = FULL_SYNC_REQUIRED_RECORD_TYPES.every( @@ -118,7 +95,40 @@ export function useCollaboration({ status: 'synced-remote', connectionStatus: 'online', }); - }; + }, + [store, sessionStore], + ); + + const handleChange = useCallback( + (key: string, value?: TLRecord) => { + // put / remove the records in the store + store.mergeRemoteChanges(() => { + if (!value) { + return store.remove([key as TLRecord['id']]); + } + if (key === CURRENT_PAGE_KEY) { + setCurrentPage(value as TLPage); + } else { + transact(() => { + store.put([value]); + if (key === TLINSTANCE_ID) { + store.put([ + { ...value, canMoveCamera: !!zoomToContent, isReadonly: !permissions.includes('write') } as TLInstance, + ]); + } + }); + } + }); + }, + [store, permissions, zoomToContent], + ); + + useEffect(() => { + if (!sessionStore) return; + + setStoreWithStatus({ status: 'loading' }); + + const unsubs: (() => void)[] = []; // Open session and sync the session store changes to the store sessionStore @@ -183,7 +193,7 @@ export function useCollaboration({ unsubs.forEach(fn => fn()); unsubs.length = 0; }; - }, [store, sessionStore, handleError]); + }, [store, sessionStore, handleChange, handleOpen, handleError]); useEffect(() => { if (!editor || !sessionStore) return; diff --git a/packages/hms-whiteboard/src/hooks/useSetEditorPermissions.tsx b/packages/hms-whiteboard/src/hooks/useSetEditorPermissions.tsx index 1c9626d5da..48a0eac2cc 100644 --- a/packages/hms-whiteboard/src/hooks/useSetEditorPermissions.tsx +++ b/packages/hms-whiteboard/src/hooks/useSetEditorPermissions.tsx @@ -33,4 +33,6 @@ export const useSetEditorPermissions = ({ const isReadonly = !permissions.includes('write'); editor?.updateInstanceState({ isReadonly }); }, [permissions, zoomToContent, editor]); + + return permissions; }; diff --git a/packages/hms-whiteboard/src/utils.ts b/packages/hms-whiteboard/src/utils.ts index b317dfc410..1a4fc22de2 100644 --- a/packages/hms-whiteboard/src/utils.ts +++ b/packages/hms-whiteboard/src/utils.ts @@ -19,3 +19,4 @@ export default function decodeJWT(token?: string) { export const CURRENT_PAGE_KEY = 'currentPage'; export const SHAPES_THROTTLE_TIME = 11; export const PAGES_DEBOUNCE_TIME = 200; +export const OPEN_WAIT_TIMEOUT = 1000; diff --git a/packages/react-sdk/src/hooks/usePreviewJoin.ts b/packages/react-sdk/src/hooks/usePreviewJoin.ts index fb1b13d85e..8da681b360 100644 --- a/packages/react-sdk/src/hooks/usePreviewJoin.ts +++ b/packages/react-sdk/src/hooks/usePreviewJoin.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react'; import { HMSConfigInitialSettings, + HMSICEServer, HMSPreviewConfig, HMSRoomState, selectIsConnectedToRoom, @@ -51,6 +52,11 @@ export interface usePreviewInput { * will be kept awake. */ autoManageWakeLock?: boolean; + + /** + * use custom STUN/TURN servers for media connection (advanced) + */ + iceServers?: HMSICEServer[]; } export interface usePreviewResult { @@ -90,6 +96,7 @@ export const usePreviewJoin = ({ asRole, autoManageVideo, autoManageWakeLock, + iceServers, }: usePreviewInput): usePreviewResult => { const actions = useHMSActions(); const roomState = useHMSStore(selectRoomState); @@ -108,6 +115,7 @@ export const usePreviewJoin = ({ captureNetworkQualityInPreview, autoManageVideo, autoManageWakeLock, + iceServers, }; }, [ name, @@ -119,6 +127,7 @@ export const usePreviewJoin = ({ asRole, autoManageVideo, autoManageWakeLock, + iceServers, ]); const preview = useCallback(async () => { diff --git a/packages/roomkit-react/src/Prebuilt/components/Chat/ChatFooter.tsx b/packages/roomkit-react/src/Prebuilt/components/Chat/ChatFooter.tsx index 51f51f2123..a5b6865219 100644 --- a/packages/roomkit-react/src/Prebuilt/components/Chat/ChatFooter.tsx +++ b/packages/roomkit-react/src/Prebuilt/components/Chat/ChatFooter.tsx @@ -102,6 +102,19 @@ export const ChatFooter = ({ onSend, children }: { onSend: (count: number) => vo } } }, [defaultSelection, selectedPeer, selectedRole, setRoleSelector, isMobile, isLandscapeHLSStream, elements?.chat]); + + const resetInputHeight = useCallback(() => { + if (inputRef.current) { + inputRef.current.style.height = `${Math.max(32, inputRef.current.value ? inputRef.current.scrollHeight : 0)}px`; + } + }, []); + + const updateInputHeight = useCallback(() => { + if (inputRef.current) { + inputRef.current.style.height = `${Math.max(32, Math.min(inputRef.current.scrollHeight, 24 * 4))}px`; + } + }, []); + const sendMessage = useCallback(async () => { const message = inputRef?.current?.value; if (!message || !message.trim().length) { @@ -116,6 +129,7 @@ export const ChatFooter = ({ onSend, children }: { onSend: (count: number) => vo await hmsActions.sendBroadcastMessage(message); } inputRef.current.value = ''; + resetInputHeight(); setTimeout(() => { onSend(1); }, 0); @@ -131,6 +145,7 @@ export const ChatFooter = ({ onSend, children }: { onSend: (count: number) => vo const messageElement = inputRef.current; if (messageElement) { messageElement.value = draftMessage; + updateInputHeight(); } }, [draftMessage]); @@ -197,11 +212,10 @@ export const ChatFooter = ({ onSend, children }: { onSend: (count: number) => vo {selection && ( vo }} autoComplete="off" aria-autocomplete="none" + onChange={updateInputHeight} + onBlur={resetInputHeight} onPaste={e => e.stopPropagation()} onCut={e => e.stopPropagation()} onCopy={e => e.stopPropagation()} diff --git a/packages/roomkit-react/src/Prebuilt/components/Footer/ChatToggle.tsx b/packages/roomkit-react/src/Prebuilt/components/Footer/ChatToggle.tsx index 8615b3bac4..4800931d32 100644 --- a/packages/roomkit-react/src/Prebuilt/components/Footer/ChatToggle.tsx +++ b/packages/roomkit-react/src/Prebuilt/components/Footer/ChatToggle.tsx @@ -21,7 +21,11 @@ export const ChatToggle = ({ onClick }: { onClick?: () => void }) => { }} > - (onClick ? onClick() : toggleChat())} active={!isChatOpen} data-testid="chat_btn"> + (onClick ? onClick() : toggleChat())} + css={{ bg: isChatOpen ? '$surface_brighter' : '' }} + data-testid="chat_btn" + > diff --git a/packages/roomkit-react/src/Prebuilt/components/Footer/ParticipantList.tsx b/packages/roomkit-react/src/Prebuilt/components/Footer/ParticipantList.tsx index 24e72b9b46..8c32d370c1 100644 --- a/packages/roomkit-react/src/Prebuilt/components/Footer/ParticipantList.tsx +++ b/packages/roomkit-react/src/Prebuilt/components/Footer/ParticipantList.tsx @@ -86,6 +86,7 @@ export const ParticipantList = ({ return { ...filterValue }; }); }, []); + if (peerCount === 0) { return null; } @@ -128,7 +129,7 @@ export const ParticipantList = ({ export const ParticipantCount = () => { const peerCount = useHMSStore(selectPeerCount); const toggleSidepane = useSidepaneToggle(SIDE_PANE_OPTIONS.PARTICIPANTS); - const isParticipantsOpen = useIsSidepaneTypeOpen(SIDE_PANE_OPTIONS.PARTICIPANTS); + const isPeerListOpen = useIsSidepaneTypeOpen(SIDE_PANE_OPTIONS.PARTICIPANTS); if (peerCount === 0) { return null; @@ -139,13 +140,13 @@ export const ParticipantCount = () => { w: 'auto', p: '$4', h: 'auto', + bg: isPeerListOpen ? '$surface_brighter' : '', }} onClick={() => { if (peerCount > 0) { toggleSidepane(); } }} - active={!isParticipantsOpen} data-testid="participant_list" > @@ -447,6 +448,7 @@ export const ParticipantSearch = ({ 300, [value, onSearch], ); + return ( { togglePollView(); setUnreadPollQuiz(false); }} - active={!isPollsOpen} + css={{ bg: isPollsOpen ? '$surface_brighter' : '' }} data-testid="polls_btn" > {unreadPollQuiz ? : } diff --git a/packages/roomkit-react/src/Prebuilt/components/Polls/Voting/LeaderboardSummary.tsx b/packages/roomkit-react/src/Prebuilt/components/Polls/Voting/LeaderboardSummary.tsx index 06475e1b1c..db2489018b 100644 --- a/packages/roomkit-react/src/Prebuilt/components/Polls/Voting/LeaderboardSummary.tsx +++ b/packages/roomkit-react/src/Prebuilt/components/Polls/Voting/LeaderboardSummary.tsx @@ -4,6 +4,8 @@ import { ChevronLeftIcon, ChevronRightIcon, CrossIcon } from '@100mslive/react-i import { Box, Flex } from '../../../../Layout'; import { Loading } from '../../../../Loading'; import { Text } from '../../../../Text'; +// @ts-ignore +import { Container } from '../../Streaming/Common'; import { LeaderboardEntry } from './LeaderboardEntry'; import { PeerParticipationSummary } from './PeerParticipationSummary'; // @ts-ignore @@ -32,80 +34,83 @@ export const LeaderboardSummary = ({ pollID }: { pollID: string }) => { const questionCount = quiz.questions?.length || 0; return ( - - - + + + + + setPollView(POLL_VIEWS.VOTE)} + > + + + + {quiz.title} + + + setPollView(POLL_VIEWS.VOTE)} + onClick={toggleSidepane} > - + - - {quiz.title} - - - - - - - - {!viewAllEntries ? : null} + + {!viewAllEntries ? : null} - - Leaderboard - - - Based on score and time taken to cast the correct answer - - - {quizLeaderboard?.entries && - quizLeaderboard.entries - .slice(0, viewAllEntries ? undefined : 5) - .map(question => ( - - ))} - {quizLeaderboard?.entries?.length > 5 && !viewAllEntries ? ( - + Leaderboard + + + Based on score and time taken to cast the correct answer + + setViewAllEntries(true)} > - View All - - ) : null} - - + {quizLeaderboard?.entries && + quizLeaderboard.entries + .slice(0, viewAllEntries ? undefined : 5) + .map(question => ( + + ))} + {quizLeaderboard?.entries?.length > 5 && !viewAllEntries ? ( + setViewAllEntries(true)} + > + View All + + ) : null} + + + + ); }; diff --git a/yarn.lock b/yarn.lock index 6be1ed9d7c..9fa5593300 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17941,4 +17941,4 @@ zustand@3.5.7: zustand@^3.6.2: version "3.7.2" resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.7.2.tgz#7b44c4f4a5bfd7a8296a3957b13e1c346f42514d" - integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA== + integrity sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA== \ No newline at end of file