From 9db42cb52254a821ee28adc7eb970d642959a0e2 Mon Sep 17 00:00:00 2001 From: efim Date: Wed, 26 Apr 2023 22:28:22 +0400 Subject: [PATCH] connect login and room pages via logged in state moving websocket into being managed by the room component. if the partent would want access to "user signal" it can ask via observer. that would mean bidirectionality, and i already hear screeching of my intuitions from react, but maybe that's ok and in react i would still scope the websocket to the room page, plus callbacks --- .../planningpoker/MyHttpService.scala | 2 +- .../sunshine/planningpoker/FrontendMain.scala | 37 ++-------- .../planningpoker/JoinRoomComponent.scala | 71 +++++++++++-------- .../sunshine/planningpoker/RoomView.scala | 39 ++++++++-- .../sunshine/planningpoker/TableView.scala | 9 ++- 5 files changed, 82 insertions(+), 76 deletions(-) diff --git a/backend/src/main/scala/industries/sunshine/planningpoker/MyHttpService.scala b/backend/src/main/scala/industries/sunshine/planningpoker/MyHttpService.scala index 3173de8..82d04be 100644 --- a/backend/src/main/scala/industries/sunshine/planningpoker/MyHttpService.scala +++ b/backend/src/main/scala/industries/sunshine/planningpoker/MyHttpService.scala @@ -28,7 +28,7 @@ object MyHttpService { Stream .emits(TestModels.testChangesList) .covary[IO] - .metered(1.second) ++ Stream.never[IO] + .metered(1.second) ) .map(state => WebSocketFrame.Text(state.asJson.noSpaces)) diff --git a/frontend/src/main/scala/industries/sunshine/planningpoker/FrontendMain.scala b/frontend/src/main/scala/industries/sunshine/planningpoker/FrontendMain.scala index 5ebfea0..75488ff 100644 --- a/frontend/src/main/scala/industries/sunshine/planningpoker/FrontendMain.scala +++ b/frontend/src/main/scala/industries/sunshine/planningpoker/FrontendMain.scala @@ -20,36 +20,8 @@ def FrontendMain(): Unit = ) object Main { - final case class AppState( - myId: Option[PlayerID] - ) - // TODO is this ok for state creation? link with auth component and use in another? - val appStateSignal = Var(AppState(Some(TestModels.me.id))).signal - import io.laminext.websocket.circe.WebSocket._ - import io.laminext.websocket.circe.webSocketReceiveBuilderSyntax - - val roomStateWSStream = io.laminext.websocket.WebSocket - .path("/api/subscribe") - .json[RoomStateView, Unit] - .build( - managed = true, - autoReconnect = false, - reconnectDelay = 1.second, - reconnectDelayOffline = 20.seconds, - reconnectRetries = 1 - ) - - val stateStream = roomStateWSStream.received - // and what's the difference between EventStream and Signal??? - val stateSignal = - stateStream.startWith(RoomStateView.empty) - - // NOTE let's try with fetch \ rest - // import io.laminext.fetch.Fetch - // import com.raquo.laminar.api.L._ - // import scala.concurrent.ExecutionContext.Implicits.global - // val testRest = Fetch.get("http://0.0.0.0:5173/api/subscribe").text + val loggedIn = Var(true) import scala.scalajs.js.Dynamic.{global => g} def appElement(): Element = { @@ -59,9 +31,10 @@ object Main { className := "h-24 w-full flex flex-for justify-center items-center bg-green-200", p(className := "text-2xl", "Here be header") ), - child <-- roomStateWSStream.isConnected.map( if (_) emptyNode else JoinRoomComponent.render()), - child <-- roomStateWSStream.isConnected.map( if (_) RoomView.renderRoom(stateSignal) else emptyNode), - roomStateWSStream.connect + child <-- loggedIn.signal.map(if (_) emptyNode else JoinRoomComponent.render(loggedIn.writer)), + child <-- loggedIn.signal.map( + if (_) RoomView.renderRoom(loggedIn.writer) else emptyNode + ), ) } } diff --git a/frontend/src/main/scala/industries/sunshine/planningpoker/JoinRoomComponent.scala b/frontend/src/main/scala/industries/sunshine/planningpoker/JoinRoomComponent.scala index ec349de..95e518f 100644 --- a/frontend/src/main/scala/industries/sunshine/planningpoker/JoinRoomComponent.scala +++ b/frontend/src/main/scala/industries/sunshine/planningpoker/JoinRoomComponent.scala @@ -48,28 +48,35 @@ object JoinRoomComponent { val (responsesStream, responseReceived) = EventStream.withCallback[FetchResponse[String]] - val submitButton = button( - "Join room", - onClick - .mapTo { - (roomNameVar.now(), roomPassVar.now(), nicknameVar.now(), nicknamePass.now()) - } - .flatMap { case (roomName, roomPass, nickname, nicknamePass) => - Fetch - .post( - "/api/login", - body = Requests.LogIn( - roomName, - nickname, - roomPass, - nicknamePass - ) - ) - .text - } --> responseReceived - ) - def render(): Element = { + def render(loggedIn: Observer[Boolean]): Element = { + + val submitButton = button( + "Join room", + onClick + .mapTo { + (roomNameVar.now(), roomPassVar.now(), nicknameVar.now(), nicknamePass.now()) + } + .flatMap { case (roomName, roomPass, nickname, nicknamePass) => + Fetch + .post( + "/api/login", + body = Requests.LogIn( + roomName, + nickname, + roomPass, + nicknamePass + ) + ) + .text.map { response => + if (response.ok) { + loggedIn.onNext(true) + response + } else response + } + } --> responseReceived + ) + div( className := "flex flex-col h-full justify-center", "Logging in:", @@ -86,19 +93,21 @@ object JoinRoomComponent { cls := "flex flex-col space-y-4 p-4 max-h-96 overflow-auto bg-gray-900", children.command <-- responsesStream.recoverToTry.map { case Success(response) => - CollectionCommand.Append( - div( + { + CollectionCommand.Append( div( - cls := "flex space-x-2 items-center", - code(cls := "text-green-500", "Status: "), - code(cls := "text-green-300", s"${response.status} ${response.statusText}") - ), - div( - cls := "text-green-400 text-xs", - code(response.data) + div( + cls := "flex space-x-2 items-center", + code(cls := "text-green-500", "Status: "), + code(cls := "text-green-300", s"${response.status} ${response.statusText}") + ), + div( + cls := "text-green-400 text-xs", + code(response.data) + ) ) ) - ) + } case Failure(exception) => CollectionCommand.Append( div( diff --git a/frontend/src/main/scala/industries/sunshine/planningpoker/RoomView.scala b/frontend/src/main/scala/industries/sunshine/planningpoker/RoomView.scala index d2c85f1..57de0ca 100644 --- a/frontend/src/main/scala/industries/sunshine/planningpoker/RoomView.scala +++ b/frontend/src/main/scala/industries/sunshine/planningpoker/RoomView.scala @@ -3,18 +3,41 @@ package industries.sunshine.planningpoker import scala.scalajs.js import com.raquo.laminar.api.L.{*, given} import industries.sunshine.planningpoker.common.Models.* +import io.laminext.websocket.circe.WebSocket._ +import io.laminext.websocket.circe.webSocketReceiveBuilderSyntax + +import scala.concurrent.duration._ -/** Rendering of the Room state - */ object RoomView { - // TODO this will take in signal of the room observable - // NOTE i guess "other players" would have to be in circle with 'me' as empty space in the bottom - def renderRoom(roomStateSignal: Signal[RoomStateView]): Element = { - // i want to number other players, for the star arrangement + + /** Rendering of the Room state central table with cards, player control UI and other players + * description + * + * @param loggedIn + * \- channel for signaling to the parent about dead websocket, i.e logged out state + */ + def renderRoom(loggedIn: Observer[Boolean]): Element = { + + val wsStream = io.laminext.websocket.WebSocket + .path("/api/subscribe") + .json[RoomStateView, Unit] + .build( + managed = true, + autoReconnect = true, + reconnectDelay = 500.millis, + reconnectDelayOffline = 20.seconds, + reconnectRetries = 3 + ) + + val roomStateSignal: Signal[RoomStateView] = + wsStream.received.startWith(RoomStateView.empty) + val otherPlayers = roomStateSignal.map { state => state.players.filterNot(_.id == state.me).zipWithIndex } + val wsFinalDeathSignal = wsStream.closed.collect { case (_, false) => () } + div( className := "w-full h-full border-4 border-amber-900 flex flex-col", div( @@ -23,7 +46,9 @@ object RoomView { renderPlayer(playerSignal, roomStateSignal.map(_.allowedCards.size)) } ), - TableView.renderTable(roomStateSignal) + TableView.renderTable(roomStateSignal), + wsStream.connect, + wsFinalDeathSignal.map(_ => false) --> loggedIn ) } diff --git a/frontend/src/main/scala/industries/sunshine/planningpoker/TableView.scala b/frontend/src/main/scala/industries/sunshine/planningpoker/TableView.scala index 279293a..74c4549 100644 --- a/frontend/src/main/scala/industries/sunshine/planningpoker/TableView.scala +++ b/frontend/src/main/scala/industries/sunshine/planningpoker/TableView.scala @@ -14,9 +14,8 @@ object TableView { def renderTable(roundSignal: Signal[RoomStateView]): Element = { val playerIdToCardTypeSignal = roundSignal - .combineWith(Main.appStateSignal.map(_.myId)) - .map((state, myIdOpt) => - state.players.map(p => p.id -> getPlayerCardType(p.id, state.round, p.name, myIdOpt)) + .map(state => + state.players.map(p => p.id -> getPlayerCardType(p.id, state.round, p.name, state.me)) ) div( @@ -36,11 +35,11 @@ object TableView { id: PlayerID, state: RoundState, name: String, - myId: Option[PlayerID] + myId: PlayerID ): CardState = { state match { case isOpen: RoundState.Voting => - if (myId.forall(_ == id)) { + if (myId == id) { isOpen.myCard.fold(NoCard(name))(vote => Open(vote)) } else isOpen.alreadyVoted.find(_.id == id).fold(NoCard(name))(_ => CardBack) case isClosed: RoundState.Viewing =>