Compare commits

...

2 Commits

Author SHA1 Message Date
qvalentin 5fbba6cb98 jotai & allchat 2023-04-08 15:58:11 +02:00
qvalentin cf1c7625be All Chat Backend 2023-04-08 15:57:33 +02:00
26 changed files with 262 additions and 73 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.1. -- This file has been generated from package.yaml by hpack version 0.35.0.
-- --
-- see: https://github.com/sol/hpack -- see: https://github.com/sol/hpack
@ -35,10 +35,12 @@ 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
@ -90,4 +92,3 @@ executable jitsi-rooms-exe
, warp , warp
, websockets , websockets
default-language: Haskell2010 default-language: Haskell2010

View File

@ -58,14 +58,4 @@ 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

@ -5,15 +5,14 @@ module BroadcastUserData
) )
where where
import ClassyPrelude import ClassyPrelude
import Data.Aeson (encode) import Data.Aeson (encode)
import qualified Network.WebSockets as WS import Network.WebSockets qualified 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
broadCastToClients :: Text -> m () broadCastToClients :: Text -> m ()
@ -32,8 +31,8 @@ broadcastUserData = do
getUsersWithoutRoom :: getUsersWithoutRoom ::
( MonadConnectedClientsRead m ( MonadConnectedClientsRead m
) => ) =>
m [Text] m [User]
getUsersWithoutRoom = map name . filter (not . joinedRoom) <$> getConnctedClients getUsersWithoutRoom = map clientToUser . filter (not . joinedRoom) <$> getConnctedClients
broadCastToClientsGeneric :: broadCastToClientsGeneric ::
( MonadIO m, ( MonadIO m,

21
backend/src/Types/User.hs Normal file
View File

@ -0,0 +1,21 @@
{-# 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

@ -5,16 +5,17 @@ module Types.UsersData
) )
where 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,
usersWithOutRoom :: UsersWithoutRoom usersWithOutRoom :: UsersWithoutRoom
} }
deriving (Generic, Show) deriving (Generic, Show)
instance ToJSON UsersData instance ToJSON UsersData
type UsersWithoutRoom = [Text] type UsersWithoutRoom = [User]

View File

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

@ -0,0 +1,18 @@
{-# 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,6 +15,7 @@ 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)
@ -23,9 +24,10 @@ import Types.WebSocketMessages.WebSocketMessages (SetClientInfo (..))
import WebSocket.Messages import WebSocket.Messages
import WebSocket.WSReaderTApp import WebSocket.WSReaderTApp
class Monad m => MonadWebSocketSession m where class MonadConnectedClientsRead 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
@ -36,6 +38,9 @@ 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,6 +13,7 @@ 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
@ -35,7 +36,8 @@ wsApp = do
handleWSAction :: handleWSAction ::
( MonadWebSocketSession m, ( MonadWebSocketSession m,
MonadConnectedClientsModify m MonadConnectedClientsModify m,
MonadBroadcast m
) => ) =>
m () m ()
handleWSAction = do handleWSAction = do
@ -45,6 +47,8 @@ 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

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

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

View File

@ -10,6 +10,7 @@
}, },
"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,3 +1,4 @@
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";
@ -9,7 +10,6 @@ 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,20 +23,12 @@ function App() {
return ( return (
<div className="App"> <div className="App">
<Sidebar usersData={roomData} <Sidebar usersData={roomData}
updateAndSubmitRoomName={(roomName: string) => { sendMessage={sendMessage}
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,7 +10,13 @@ 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: string[]; usersWithOutRoom: User[];
} }

View File

@ -0,0 +1,20 @@
.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

@ -0,0 +1,53 @@
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,5 +1,6 @@
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";
@ -9,17 +10,12 @@ 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, roomName, updateRoomName, submitRoomName, meetingStarted, setMeetingStarted }: Props) { function Meeting({ conferenceData, setConferenceData, userInfo }: Props) {
const startMeeting = useCallback(() => { const [meetingStarted, setMeetingStarted] = useMeetingStarted()
submitRoomName(); const { roomName } = useRoomName()
setMeetingStarted(true);
}, [submitRoomName, setMeetingStarted]);
if (meetingStarted) { if (meetingStarted) {
return ( return (
@ -35,8 +31,6 @@ function Meeting({ conferenceData, setConferenceData, userInfo, roomName, update
return ( return (
<MeetingNameInput <MeetingNameInput
roomName={roomName} roomName={roomName}
setName={updateRoomName}
submit={startMeeting}
currentUser={userInfo.displayName} currentUser={userInfo.displayName}
/> />
); );

View File

@ -1,26 +1,35 @@
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; roomName: string; currentUser: 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) => {
props.setName(event.target.value); updateRoomName(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={props.submit}> <form onSubmit={onSubmit}>
<input <input
placeholder="Roomname" placeholder="Roomname"
type="text" type="text"
value={props.roomName} value={roomName}
onChange={onInput} onChange={onInput}
/> />
<button type="submit">Enter the adventure</button> <button type="submit">Enter the adventure</button>

View File

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

View File

@ -2,15 +2,20 @@ 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;
updateAndSubmitRoomName: Function sendMessage: 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}`}>
@ -21,13 +26,14 @@ function Sidebar(props: Props) {
<> <>
<h3> <h3>
<a href="#" onClick={() => { <a href="#" onClick={() => {
props.updateAndSubmitRoomName(roomData.roomName) updateAndSubmitRoomName(roomData.roomName)
setMeetingStarted(true)
}}> }}>
{roomData.roomName} {roomData.roomName}
</a> </a>
</h3> </h3>
{roomData.participants.map((participant) => ( {roomData.participants.map((participant) => (
<div> {participant.displayName} </div> <div key={participant.jid}> {participant.displayName} </div>
))} ))}
</> </>
); );
@ -35,12 +41,13 @@ function Sidebar(props: Props) {
</div> </div>
<div> <div>
<h3> No room</h3> <h3> No room</h3>
{props.usersData.usersWithOutRoom.map((username) => ( {props.usersData.usersWithOutRoom.map((user) => (
<div>{username}</div> <div key={user.uuid}>{user.name}</div>
))} ))}
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<button onClick={toggleSidebarVisibility}>{sidebarToggleText}</button> <Chat sendMessage={props.sendMessage} />
<button className="sidebar-toggle" onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,27 @@
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,5 +1,6 @@
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";
@ -10,12 +11,15 @@ 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

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

View File

@ -1,7 +1,10 @@
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] = useState(() => getRoomNameFromUrl()); const [roomName, setRoomName] = useAtom(roomNameAtom)
const updateRoomName = useCallback( const updateRoomName = useCallback(
(newName: string) => { (newName: string) => {

View File

@ -1,3 +1,4 @@
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'
@ -5,6 +6,8 @@ import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <Provider>
<App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@ -543,6 +543,11 @@ 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"