Compare commits

..

No commits in common. "5fbba6cb98d59f5c96c1c6d3de0baa34855ae7cf" and "b01b637a2220b8aaef6d658ede34258001f0a7d9" have entirely different histories.

26 changed files with 73 additions and 262 deletions

View File

@ -1,6 +1,6 @@
cabal-version: 1.12 cabal-version: 1.12
-- This file has been generated from package.yaml by hpack version 0.35.0. -- This file has been generated from package.yaml by hpack version 0.35.1.
-- --
-- see: https://github.com/sol/hpack -- see: https://github.com/sol/hpack
@ -35,12 +35,10 @@ library
Types.Participant Types.Participant
Types.RoomData Types.RoomData
Types.RoomsState Types.RoomsState
Types.User
Types.UsersData Types.UsersData
Types.WebEnv Types.WebEnv
Types.WebSocketMessages.WebSocketMessages Types.WebSocketMessages.WebSocketMessages
WebServer WebServer
WebSocket.AllChat
WebSocket.Messages WebSocket.Messages
WebSocket.MonadWebSocketSession WebSocket.MonadWebSocketSession
WebSocket.Server WebSocket.Server
@ -92,3 +90,4 @@ executable jitsi-rooms-exe
, warp , warp
, websockets , websockets
default-language: Haskell2010 default-language: Haskell2010

View File

@ -58,4 +58,14 @@ executables:
dependencies: dependencies:
- jitsi-rooms - jitsi-rooms
tests:
jitsi-rooms-test:
main: Spec.hs
source-dirs: test
ghc-options:
- -threaded
- -rtsopts
- -with-rtsopts=-N
dependencies:
- jitsi-rooms
default-extensions: NoImplicitPrelude,OverloadedStrings,ImportQualifiedPost default-extensions: NoImplicitPrelude,OverloadedStrings,ImportQualifiedPost

View File

@ -7,11 +7,12 @@ where
import ClassyPrelude import ClassyPrelude
import Data.Aeson (encode) import Data.Aeson (encode)
import Network.WebSockets qualified as WS import qualified Network.WebSockets as WS
import State.ConnectedClientsState (MonadConnectedClientsRead (getConnctedClients)) import State.ConnectedClientsState (MonadConnectedClientsRead (getConnctedClients))
import State.RoomDataState (MonadRoomDataStateRead (getRoomDataState)) import State.RoomDataState (MonadRoomDataStateRead (getRoomDataState))
import Types.AppTypes (HasConnectedClientState (..))
import Types.ConnectionState (Client (..), ConnectedClients) import Types.ConnectionState (Client (..), ConnectedClients)
import Types.User (User, clientToUser) import Types.RoomsState (HasRoomsState (..))
import Types.UsersData (UsersData (..)) import Types.UsersData (UsersData (..))
class (Monad m, MonadConnectedClientsRead m) => MonadBroadcast m where class (Monad m, MonadConnectedClientsRead m) => MonadBroadcast m where
@ -31,8 +32,8 @@ broadcastUserData = do
getUsersWithoutRoom :: getUsersWithoutRoom ::
( MonadConnectedClientsRead m ( MonadConnectedClientsRead m
) => ) =>
m [User] m [Text]
getUsersWithoutRoom = map clientToUser . filter (not . joinedRoom) <$> getConnctedClients getUsersWithoutRoom = map name . filter (not . joinedRoom) <$> getConnctedClients
broadCastToClientsGeneric :: broadCastToClientsGeneric ::
( MonadIO m, ( MonadIO m,

View File

@ -1,21 +0,0 @@
{-# LANGUAGE DeriveGeneric #-}
module Types.User (User, clientToUser) where
import ClassyPrelude
import Data.Aeson (FromJSON, ToJSON)
import Data.UUID (UUID)
import Types.ConnectionState qualified as C
data User = User
{ uuid :: UUID,
name :: Text
}
deriving (Generic, Show, Eq)
clientToUser :: C.Client -> User
clientToUser C.Client {C.uuid = userUuid, C.name = userName, C.joinedRoom = _, C.conn = _} = User userUuid userName
instance ToJSON User
instance FromJSON User

View File

@ -8,7 +8,6 @@ where
import ClassyPrelude import ClassyPrelude
import Data.Aeson (ToJSON) import Data.Aeson (ToJSON)
import Types.RoomData (RoomsData) import Types.RoomData (RoomsData)
import Types.User (User)
data UsersData = UsersData data UsersData = UsersData
{ roomsData :: RoomsData, { roomsData :: RoomsData,
@ -18,4 +17,4 @@ data UsersData = UsersData
instance ToJSON UsersData instance ToJSON UsersData
type UsersWithoutRoom = [User] type UsersWithoutRoom = [Text]

View File

@ -1,12 +1,9 @@
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
module Types.WebSocketMessages.WebSocketMessages module Types.WebSocketMessages.WebSocketMessages
( WebSocketMessage (..), ( WebSocketMessage (..),
SetClientInfo (..), SetClientInfo (..),
JoinRoom (..), JoinRoom (..),
AllChatMessageIncoming (..),
AllChatMessageOutgoing (..),
) )
where where
@ -15,13 +12,14 @@ import Data.Aeson
( FromJSON (parseJSON), ( FromJSON (parseJSON),
Options (sumEncoding), Options (sumEncoding),
SumEncoding (..), SumEncoding (..),
ToJSON, decode,
defaultOptions, defaultOptions,
genericParseJSON, genericParseJSON,
withObject,
(.:),
) )
import Types.User (User)
data WebSocketMessage = ClientInfoMessage SetClientInfo | JoinRoomMessage JoinRoom | AllChatMessageIncomingMessage AllChatMessageIncoming data WebSocketMessage = ClientInfoMessage SetClientInfo | JoinRoomMessage JoinRoom
deriving (Generic) deriving (Generic)
instance FromJSON WebSocketMessage where instance FromJSON WebSocketMessage where
@ -40,18 +38,3 @@ data JoinRoom = JoinRoom
deriving (Generic, Show) deriving (Generic, Show)
instance FromJSON JoinRoom instance FromJSON JoinRoom
data AllChatMessageIncoming = AllChatMessageIncoming
{ content :: Text
}
deriving (Generic, Show)
instance FromJSON AllChatMessageIncoming
data AllChatMessageOutgoing = AllChatMessageOutgoing
{ content :: Text,
sender :: User
}
deriving (Generic, Show)
instance ToJSON AllChatMessageOutgoing

View File

@ -1,18 +0,0 @@
{-# LANGUAGE LambdaCase #-}
module WebSocket.AllChat (broadCastAllChatMessage) where
import BroadcastUserData (MonadBroadcast (..))
import ClassyPrelude
import Data.Aeson (encode)
import Types.User (clientToUser)
import Types.WebSocketMessages.WebSocketMessages (AllChatMessageIncoming (..), AllChatMessageOutgoing (AllChatMessageOutgoing))
import WebSocket.MonadWebSocketSession (MonadWebSocketSession (getClient))
broadCastAllChatMessage :: (MonadBroadcast m, MonadWebSocketSession m) => AllChatMessageIncoming -> m ()
broadCastAllChatMessage AllChatMessageIncoming {content = message} = do
getClient >>= \case
Nothing -> return ()
Just client -> do
let broadCastValue = AllChatMessageOutgoing message (clientToUser client)
broadCastToClients $ (decodeUtf8 . toStrict . encode) broadCastValue

View File

@ -15,7 +15,6 @@ import Data.Aeson
import Data.UUID (UUID) import Data.UUID (UUID)
import State.ConnectedClientsState import State.ConnectedClientsState
( MonadConnectedClientsModify, ( MonadConnectedClientsModify,
MonadConnectedClientsRead (getConnctedClients),
removeWSClient, removeWSClient,
) )
import State.RoomDataState (MonadRoomDataStateRead) import State.RoomDataState (MonadRoomDataStateRead)
@ -24,10 +23,9 @@ import Types.WebSocketMessages.WebSocketMessages (SetClientInfo (..))
import WebSocket.Messages import WebSocket.Messages
import WebSocket.WSReaderTApp import WebSocket.WSReaderTApp
class MonadConnectedClientsRead m => MonadWebSocketSession m where class Monad m => MonadWebSocketSession m where
getTypedWSMessage :: FromJSON a => m a getTypedWSMessage :: FromJSON a => m a
getSesssionId :: m UUID getSesssionId :: m UUID
getClient :: m (Maybe Client)
instance MonadWebSocketSession (WSApp WSEnv) where instance MonadWebSocketSession (WSApp WSEnv) where
getTypedWSMessage = do getTypedWSMessage = do
@ -38,9 +36,6 @@ instance MonadWebSocketSession (WSApp WSEnv) where
sendMessage $ "Bad message: " <> pack err sendMessage $ "Bad message: " <> pack err
getTypedWSMessage getTypedWSMessage
getSesssionId = getClientId <$> ask getSesssionId = getClientId <$> ask
getClient = do
id' <- getSesssionId
find ((== id') . uuid) <$> getConnctedClients
class (Monad m) => MonadWebSocketSessionInit m where class (Monad m) => MonadWebSocketSessionInit m where
newClient :: SetClientInfo -> m Client newClient :: SetClientInfo -> m Client

View File

@ -13,7 +13,6 @@ import Types.WebSocketMessages.WebSocketMessages
( SetClientInfo (displayName), ( SetClientInfo (displayName),
WebSocketMessage (..), WebSocketMessage (..),
) )
import WebSocket.AllChat (broadCastAllChatMessage)
import WebSocket.MonadWebSocketSession import WebSocket.MonadWebSocketSession
import WebSocket.WSReaderTApp import WebSocket.WSReaderTApp
@ -36,8 +35,7 @@ wsApp = do
handleWSAction :: handleWSAction ::
( MonadWebSocketSession m, ( MonadWebSocketSession m,
MonadConnectedClientsModify m, MonadConnectedClientsModify m
MonadBroadcast m
) => ) =>
m () m ()
handleWSAction = do handleWSAction = do
@ -47,8 +45,6 @@ handleWSAction = do
joinRoom joinRoom
ClientInfoMessage clientInfo -> do ClientInfoMessage clientInfo -> do
updateClientName clientInfo updateClientName clientInfo
AllChatMessageIncomingMessage incomingMessage -> do
broadCastAllChatMessage incomingMessage
joinRoom :: joinRoom ::
( MonadConnectedClientsModify m, ( MonadConnectedClientsModify m,

View File

@ -0,0 +1,2 @@
main :: IO ()
main = putStrLn "Test suite not yet implemented"

0
frontend/dev-with-remote-backend.sh Executable file → Normal file
View File

View File

@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@jitsi/react-sdk": "^1.3.0", "@jitsi/react-sdk": "^1.3.0",
"jotai": "^2.0.3",
"just-curry-it": "^5.3.0", "just-curry-it": "^5.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"

View File

@ -1,4 +1,3 @@
import { Provider } from "jotai";
import { useState } from "react"; import { useState } from "react";
import "./App.css"; import "./App.css";
import Meeting from "./components/meeting/Meeting"; import Meeting from "./components/meeting/Meeting";
@ -10,6 +9,7 @@ import { useRoomName } from "./hooks/useRoomName";
function App() { function App() {
const { userInfo, setUserInfo } = useLocalUser(); const { userInfo, setUserInfo } = useLocalUser();
const { roomName, updateRoomName, updateAndSubmitRoomName, submitRoomName } = useRoomName();
const { roomData, sendMessage } = useBackendData(userInfo); const { roomData, sendMessage } = useBackendData(userInfo);
const { conferenceData, setConferenceData } = useConferenceData( const { conferenceData, setConferenceData } = useConferenceData(
sendMessage, sendMessage,
@ -23,12 +23,20 @@ function App() {
return ( return (
<div className="App"> <div className="App">
<Sidebar usersData={roomData} <Sidebar usersData={roomData}
sendMessage={sendMessage} updateAndSubmitRoomName={(roomName: string) => {
updateAndSubmitRoomName(roomName)
setMeetingStarted(true)
}}
/> />
<Meeting <Meeting
conferenceData={conferenceData} conferenceData={conferenceData}
setConferenceData={setConferenceData} setConferenceData={setConferenceData}
userInfo={userInfo} userInfo={userInfo}
roomName={roomName}
updateRoomName={updateRoomName}
submitRoomName={submitRoomName}
meetingStarted={meetingStarted}
setMeetingStarted={setMeetingStarted}
/> />
</div> </div>
); );

View File

@ -10,13 +10,7 @@ export interface Participant {
email: string; email: string;
} }
export interface User {
uuid: string,
name: string
}
export interface UsersData { export interface UsersData {
roomsData: RoomData[]; roomsData: RoomData[];
usersWithOutRoom: User[]; usersWithOutRoom: string[];
} }

View File

@ -1,20 +0,0 @@
.chat-input{
max-width: 50%;
}
.chat-bubble{
background-color: #3d3d5c;
overflow-wrap: break-word;
margin-bottom: 7px;
white-space: -moz-pre-wrap !important; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
white-space: pre-wrap; /* css-3 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
white-space: -webkit-pre-wrap; /* Newer versions of Chrome/Safari*/
word-break: break-all;
white-space: normal;
}

View File

@ -1,53 +0,0 @@
import { useState } from "react";
import { FormEventHandler } from "react";
import useAllChat from "../../hooks/useAllChat";
import "./Chat.css"
interface Props {
sendMessage: Function
}
function Chat({ sendMessage }: Props) {
const [chatInput, setChatInput] = useState("")
const { chatMesages } = useAllChat()
const onInput: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setChatInput(event.target.value);
event.preventDefault();
};
const onSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
sendMessage(JSON.stringify({ content: chatInput }));
setChatInput("")
}
return (
<div className="">
{chatMesages.map(message => (
<div className="chat-bubble"> <span>{message.sender.name}: </span> {message.content} </div>
))
}
<form onSubmit={onSubmit}>
<input
className="chat-input"
placeholder="Say something funny :D"
type="text"
value={chatInput}
onChange={onInput}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
export default Chat

View File

@ -1,6 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ConferenceData } from "../../background/jitsi/eventListeners"; import { ConferenceData } from "../../background/jitsi/eventListeners";
import useMeetingStarted from "../../hooks/useMeetingStarted";
import { useRoomName } from "../../hooks/useRoomName"; import { useRoomName } from "../../hooks/useRoomName";
import JitsiEntrypoint from "../jitsi/JitsiEntrypoint"; import JitsiEntrypoint from "../jitsi/JitsiEntrypoint";
import { UserInfo } from "../jitsi/types"; import { UserInfo } from "../jitsi/types";
@ -10,12 +9,17 @@ interface Props {
conferenceData: ConferenceData | undefined; conferenceData: ConferenceData | undefined;
setConferenceData: (newData: ConferenceData) => void; setConferenceData: (newData: ConferenceData) => void;
userInfo: UserInfo; userInfo: UserInfo;
//@ts-ignore
roomName, updateRoomName, submitRoomName
meetingStarted: Boolean, setMeetingStarted: Function
} }
function Meeting({ conferenceData, setConferenceData, userInfo }: Props) { function Meeting({ conferenceData, setConferenceData, userInfo, roomName, updateRoomName, submitRoomName, meetingStarted, setMeetingStarted }: Props) {
const [meetingStarted, setMeetingStarted] = useMeetingStarted() const startMeeting = useCallback(() => {
const { roomName } = useRoomName() submitRoomName();
setMeetingStarted(true);
}, [submitRoomName, setMeetingStarted]);
if (meetingStarted) { if (meetingStarted) {
return ( return (
@ -31,6 +35,8 @@ function Meeting({ conferenceData, setConferenceData, userInfo }: Props) {
return ( return (
<MeetingNameInput <MeetingNameInput
roomName={roomName} roomName={roomName}
setName={updateRoomName}
submit={startMeeting}
currentUser={userInfo.displayName} currentUser={userInfo.displayName}
/> />
); );

View File

@ -1,35 +1,26 @@
import { FormEventHandler } from "react"; import { FormEventHandler } from "react";
import useMeetingStarted from "../../hooks/useMeetingStarted";
import { useRoomName } from "../../hooks/useRoomName";
import "./MeetingNameInput.css"; import "./MeetingNameInput.css";
function MeetingNameInput(props: { function MeetingNameInput(props: {
roomName: string; currentUser: string; roomName: string;
setName: (name: string) => void;
submit: FormEventHandler;
currentUser: string;
}) { }) {
const { roomName, updateRoomName, updateAndSubmitRoomName, submitRoomName } = useRoomName()
const [_, setMeetingStarted] = useMeetingStarted()
const onInput: React.ChangeEventHandler<HTMLInputElement> = (event) => { const onInput: React.ChangeEventHandler<HTMLInputElement> = (event) => {
updateRoomName(event.target.value); props.setName(event.target.value);
event.preventDefault(); event.preventDefault();
}; };
const onSubmit: FormEventHandler<HTMLFormElement> = (event) => {
submitRoomName()
setMeetingStarted(true)
event.preventDefault();
}
console.log("[Rooms] MeetingName input comp"); console.log("[Rooms] MeetingName input comp");
return ( return (
<div className="meeting-name-input"> <div className="meeting-name-input">
<h1>Greetings {props.currentUser}</h1> <h1>Greetings {props.currentUser}</h1>
<form onSubmit={onSubmit}> <form onSubmit={props.submit}>
<input <input
placeholder="Roomname" placeholder="Roomname"
type="text" type="text"
value={roomName} value={props.roomName}
onChange={onInput} onChange={onInput}
/> />
<button type="submit">Enter the adventure</button> <button type="submit">Enter the adventure</button>

View File

@ -25,11 +25,7 @@
max-width: 220px; max-width: 220px;
} }
.sidebar-hidden { .sidebar-hidden > .sidebar-footer {
width: 0;
}
.sidebar-hidden > .sidebar-footer > .sidebar-toggle {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
} }

View File

@ -2,20 +2,15 @@ import SidebarHeader from "./SidebarHeader";
import useSidebarVisibility from "./useSidebarVisibility"; import useSidebarVisibility from "./useSidebarVisibility";
import "./Sidebar.css"; import "./Sidebar.css";
import { UsersData } from "../../background/types/roomData"; import { UsersData } from "../../background/types/roomData";
import useMeetingStarted from "../../hooks/useMeetingStarted";
import { useRoomName } from "../../hooks/useRoomName";
import Chat from "../chat/Chat";
interface Props { interface Props {
usersData: UsersData; usersData: UsersData;
sendMessage: Function updateAndSubmitRoomName: Function
} }
function Sidebar(props: Props) { function Sidebar(props: Props) {
const { sidebarVisibility, toggleSidebarVisibility, sidebarToggleText } = const { sidebarVisibility, toggleSidebarVisibility, sidebarToggleText } =
useSidebarVisibility(); useSidebarVisibility();
const [_, setMeetingStarted] = useMeetingStarted()
const { updateAndSubmitRoomName: updateAndSubmitRoomName } = useRoomName();
return ( return (
<div className={`sidebar sidebar-${sidebarVisibility}`}> <div className={`sidebar sidebar-${sidebarVisibility}`}>
@ -26,14 +21,13 @@ function Sidebar(props: Props) {
<> <>
<h3> <h3>
<a href="#" onClick={() => { <a href="#" onClick={() => {
updateAndSubmitRoomName(roomData.roomName) props.updateAndSubmitRoomName(roomData.roomName)
setMeetingStarted(true)
}}> }}>
{roomData.roomName} {roomData.roomName}
</a> </a>
</h3> </h3>
{roomData.participants.map((participant) => ( {roomData.participants.map((participant) => (
<div key={participant.jid}> {participant.displayName} </div> <div> {participant.displayName} </div>
))} ))}
</> </>
); );
@ -41,13 +35,12 @@ function Sidebar(props: Props) {
</div> </div>
<div> <div>
<h3> No room</h3> <h3> No room</h3>
{props.usersData.usersWithOutRoom.map((user) => ( {props.usersData.usersWithOutRoom.map((username) => (
<div key={user.uuid}>{user.name}</div> <div>{username}</div>
))} ))}
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<Chat sendMessage={props.sendMessage} /> <button onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
<button className="sidebar-toggle" onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
</div> </div>
</div> </div>
); );

View File

@ -1,27 +0,0 @@
import { atom, useAtom } from "jotai";
import { atomWithReducer, useReducerAtom } from 'jotai/utils'
import { User } from "../background/types/roomData";
interface ChatMessage {
content: string
sender: User
}
export const allChatMessagesAtom = atomWithReducer([], (list: ChatMessage[], item: ChatMessage) => list.concat(item))
const useAllChat = () => {
const [chatMessages, addChatMessage] = useAtom(allChatMessagesAtom)
return { chatMesages: chatMessages, addChatMessage }
}
export default useAllChat

View File

@ -1,6 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { UserInfo } from "../components/jitsi/types"; import { UserInfo } from "../components/jitsi/types";
import useAllChat from "./useAllChat";
import useRoomData from "./useRoomData"; import useRoomData from "./useRoomData";
import useWebSocketConnection from "./useWebSocketConnection"; import useWebSocketConnection from "./useWebSocketConnection";
@ -11,15 +10,12 @@ function useBackendData(userInfo: UserInfo) {
const { roomData, setRoomData } = useRoomData(); const { roomData, setRoomData } = useRoomData();
const { chatMesages, addChatMessage } = useAllChat()
useEffect(() => { useEffect(() => {
onMessage((messageString) => { onMessage((messageString) => {
console.log("[Rooms] message from ws", messageString); console.log("[Rooms] message from ws", messageString);
const messageObject = JSON.parse(messageString); const messageObject = JSON.parse(messageString);
!!messageObject.roomsData && setRoomData(messageObject); !!messageObject.roomsData && setRoomData(messageObject);
!!messageObject.content && addChatMessage(messageObject);
return disconnect; return disconnect;
}); });
}, [onMessage, setRoomData, disconnect]); }, [onMessage, setRoomData, disconnect]);

View File

@ -1,7 +0,0 @@
import { atom, useAtom } from "jotai";
const meetingStarted = atom(false)
const useMeetingStarted = () => useAtom(meetingStarted);
export default useMeetingStarted

View File

@ -1,10 +1,7 @@
import { atom, useAtom } from "jotai";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
const roomNameAtom = atom(getRoomNameFromUrl())
function useRoomName() { function useRoomName() {
const [roomName, setRoomName] = useAtom(roomNameAtom) const [roomName, setRoomName] = useState(() => getRoomNameFromUrl());
const updateRoomName = useCallback( const updateRoomName = useCallback(
(newName: string) => { (newName: string) => {

View File

@ -1,4 +1,3 @@
import { Provider } from 'jotai'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
@ -6,8 +5,6 @@ import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<Provider>
<App /> <App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@ -543,11 +543,6 @@ is-core-module@^2.9.0:
dependencies: dependencies:
has "^1.0.3" has "^1.0.3"
jotai@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/jotai/-/jotai-2.0.3.tgz#3b67cda9f6d5feb70a14db0b842a9873aacda8b5"
integrity sha512-MMjhSPAL3RoeZD9WbObufRT2quThEAEknHHridf2ma8Ml7ZVQmUiHk0ssdbR3F0h3kcwhYqSGJ59OjhPge7RRg==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"