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
-- 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
@ -35,10 +35,12 @@ library
Types.Participant
Types.RoomData
Types.RoomsState
Types.User
Types.UsersData
Types.WebEnv
Types.WebSocketMessages.WebSocketMessages
WebServer
WebSocket.AllChat
WebSocket.Messages
WebSocket.MonadWebSocketSession
WebSocket.Server
@ -90,4 +92,3 @@ executable jitsi-rooms-exe
, warp
, websockets
default-language: Haskell2010

View File

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

View File

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

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

View File

@ -1,9 +1,12 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
module Types.WebSocketMessages.WebSocketMessages
( WebSocketMessage (..),
SetClientInfo (..),
JoinRoom (..),
AllChatMessageIncoming (..),
AllChatMessageOutgoing (..),
)
where
@ -12,14 +15,13 @@ import Data.Aeson
( FromJSON (parseJSON),
Options (sumEncoding),
SumEncoding (..),
decode,
ToJSON,
defaultOptions,
genericParseJSON,
withObject,
(.:),
)
import Types.User (User)
data WebSocketMessage = ClientInfoMessage SetClientInfo | JoinRoomMessage JoinRoom
data WebSocketMessage = ClientInfoMessage SetClientInfo | JoinRoomMessage JoinRoom | AllChatMessageIncomingMessage AllChatMessageIncoming
deriving (Generic)
instance FromJSON WebSocketMessage where
@ -38,3 +40,18 @@ data JoinRoom = JoinRoom
deriving (Generic, Show)
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 State.ConnectedClientsState
( MonadConnectedClientsModify,
MonadConnectedClientsRead (getConnctedClients),
removeWSClient,
)
import State.RoomDataState (MonadRoomDataStateRead)
@ -23,9 +24,10 @@ import Types.WebSocketMessages.WebSocketMessages (SetClientInfo (..))
import WebSocket.Messages
import WebSocket.WSReaderTApp
class Monad m => MonadWebSocketSession m where
class MonadConnectedClientsRead m => MonadWebSocketSession m where
getTypedWSMessage :: FromJSON a => m a
getSesssionId :: m UUID
getClient :: m (Maybe Client)
instance MonadWebSocketSession (WSApp WSEnv) where
getTypedWSMessage = do
@ -36,6 +38,9 @@ instance MonadWebSocketSession (WSApp WSEnv) where
sendMessage $ "Bad message: " <> pack err
getTypedWSMessage
getSesssionId = getClientId <$> ask
getClient = do
id' <- getSesssionId
find ((== id') . uuid) <$> getConnctedClients
class (Monad m) => MonadWebSocketSessionInit m where
newClient :: SetClientInfo -> m Client

View File

@ -13,6 +13,7 @@ import Types.WebSocketMessages.WebSocketMessages
( SetClientInfo (displayName),
WebSocketMessage (..),
)
import WebSocket.AllChat (broadCastAllChatMessage)
import WebSocket.MonadWebSocketSession
import WebSocket.WSReaderTApp
@ -35,7 +36,8 @@ wsApp = do
handleWSAction ::
( MonadWebSocketSession m,
MonadConnectedClientsModify m
MonadConnectedClientsModify m,
MonadBroadcast m
) =>
m ()
handleWSAction = do
@ -45,6 +47,8 @@ handleWSAction = do
joinRoom
ClientInfoMessage clientInfo -> do
updateClientName clientInfo
AllChatMessageIncomingMessage incomingMessage -> do
broadCastAllChatMessage incomingMessage
joinRoom ::
( 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": {
"@jitsi/react-sdk": "^1.3.0",
"jotai": "^2.0.3",
"just-curry-it": "^5.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"

View File

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

View File

@ -10,7 +10,13 @@ export interface Participant {
email: string;
}
export interface User {
uuid: string,
name: string
}
export interface UsersData {
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 { ConferenceData } from "../../background/jitsi/eventListeners";
import useMeetingStarted from "../../hooks/useMeetingStarted";
import { useRoomName } from "../../hooks/useRoomName";
import JitsiEntrypoint from "../jitsi/JitsiEntrypoint";
import { UserInfo } from "../jitsi/types";
@ -9,17 +10,12 @@ interface Props {
conferenceData: ConferenceData | undefined;
setConferenceData: (newData: ConferenceData) => void;
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(() => {
submitRoomName();
setMeetingStarted(true);
}, [submitRoomName, setMeetingStarted]);
const [meetingStarted, setMeetingStarted] = useMeetingStarted()
const { roomName } = useRoomName()
if (meetingStarted) {
return (
@ -35,8 +31,6 @@ function Meeting({ conferenceData, setConferenceData, userInfo, roomName, update
return (
<MeetingNameInput
roomName={roomName}
setName={updateRoomName}
submit={startMeeting}
currentUser={userInfo.displayName}
/>
);

View File

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

View File

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

View File

@ -2,15 +2,20 @@ import SidebarHeader from "./SidebarHeader";
import useSidebarVisibility from "./useSidebarVisibility";
import "./Sidebar.css";
import { UsersData } from "../../background/types/roomData";
import useMeetingStarted from "../../hooks/useMeetingStarted";
import { useRoomName } from "../../hooks/useRoomName";
import Chat from "../chat/Chat";
interface Props {
usersData: UsersData;
updateAndSubmitRoomName: Function
sendMessage: Function
}
function Sidebar(props: Props) {
const { sidebarVisibility, toggleSidebarVisibility, sidebarToggleText } =
useSidebarVisibility();
const [_, setMeetingStarted] = useMeetingStarted()
const { updateAndSubmitRoomName: updateAndSubmitRoomName } = useRoomName();
return (
<div className={`sidebar sidebar-${sidebarVisibility}`}>
@ -21,13 +26,14 @@ function Sidebar(props: Props) {
<>
<h3>
<a href="#" onClick={() => {
props.updateAndSubmitRoomName(roomData.roomName)
updateAndSubmitRoomName(roomData.roomName)
setMeetingStarted(true)
}}>
{roomData.roomName}
</a>
</h3>
{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>
<h3> No room</h3>
{props.usersData.usersWithOutRoom.map((username) => (
<div>{username}</div>
{props.usersData.usersWithOutRoom.map((user) => (
<div key={user.uuid}>{user.name}</div>
))}
</div>
<div className="sidebar-footer">
<button onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
<Chat sendMessage={props.sendMessage} />
<button className="sidebar-toggle" onClick={toggleSidebarVisibility}>{sidebarToggleText}</button>
</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 { UserInfo } from "../components/jitsi/types";
import useAllChat from "./useAllChat";
import useRoomData from "./useRoomData";
import useWebSocketConnection from "./useWebSocketConnection";
@ -10,12 +11,15 @@ function useBackendData(userInfo: UserInfo) {
const { roomData, setRoomData } = useRoomData();
const { chatMesages, addChatMessage } = useAllChat()
useEffect(() => {
onMessage((messageString) => {
console.log("[Rooms] message from ws", messageString);
const messageObject = JSON.parse(messageString);
!!messageObject.roomsData && setRoomData(messageObject);
!!messageObject.content && addChatMessage(messageObject);
return 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";
const roomNameAtom = atom(getRoomNameFromUrl())
function useRoomName() {
const [roomName, setRoomName] = useState(() => getRoomNameFromUrl());
const [roomName, setRoomName] = useAtom(roomNameAtom)
const updateRoomName = useCallback(
(newName: string) => {

View File

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

View File

@ -543,6 +543,11 @@ is-core-module@^2.9.0:
dependencies:
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:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"