diff --git a/core/src/scalive/Scalive.scala b/core/src/scalive/Scalive.scala index 2350b41..b6ed40b 100644 --- a/core/src/scalive/Scalive.scala +++ b/core/src/scalive/Scalive.scala @@ -8,6 +8,10 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: lazy val defer = htmlAttr("defer", codecs.BooleanAsAttrPresenceCodec) + object link: + def navigate(path: String, mods: Mod*): HtmlElement = + a(href := path, phx.link := "redirect", phx.linkState := "push", mods) + object phx: private def phxAttr(suffix: String): HtmlAttr[String] = new HtmlAttr(s"phx-$suffix", StringAsIsCodec) @@ -16,10 +20,12 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: private def dataPhxAttr(suffix: String): HtmlAttr[String] = dataAttr(s"phx-$suffix") - private[scalive] lazy val session = dataPhxAttr("session") - private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec) - lazy val click = phxAttrJson("click") - def value(key: String) = phxAttr(s"value-$key") + private[scalive] lazy val session = dataPhxAttr("session") + private[scalive] lazy val main = htmlAttr("data-phx-main", BooleanAsAttrPresenceCodec) + private[scalive] lazy val link = dataPhxAttr("link") + private[scalive] lazy val linkState = dataPhxAttr("link-state") + lazy val click = phxAttrJson("click") + def value(key: String) = phxAttr(s"value-$key") implicit def stringToMod(v: String): Mod = Mod.Content.Text(v) implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el) diff --git a/core/src/scalive/WebSocketMessage.scala b/core/src/scalive/WebSocketMessage.scala index 75c9533..903e7ff 100644 --- a/core/src/scalive/WebSocketMessage.scala +++ b/core/src/scalive/WebSocketMessage.scala @@ -16,7 +16,15 @@ final case class WebSocketMessage( topic: String, eventType: String, payload: WebSocketMessage.Payload): - val meta = WebSocketMessage.Meta(joinRef, messageRef, topic, eventType) + val meta = WebSocketMessage.Meta(joinRef, messageRef, topic, eventType) + def okReply = + WebSocketMessage( + joinRef, + messageRef, + topic, + "phx_reply", + Payload.Reply("ok", LiveResponse.Empty) + ) object WebSocketMessage: final case class Meta( @@ -33,6 +41,8 @@ object WebSocketMessage: val payloadParsed = eventType match case "heartbeat" => Right(Payload.Heartbeat) case "phx_join" => payload.as[Payload.Join] + case "phx_leave" => Right(Payload.Leave) + case "phx_close" => Right(Payload.Close) case "event" => payload.as[Payload.Event] case s => Left(s"Unknown event type : $s") @@ -56,6 +66,8 @@ object WebSocketMessage: m.payload match case Payload.Heartbeat => Json.Obj.empty case p: Payload.Join => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) + case Payload.Leave => Json.Obj.empty + case Payload.Close => Json.Obj.empty case p: Payload.Reply => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) case p: Payload.Event => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) case p: Payload.Diff => p.toJsonAST.getOrElse(throw new IllegalArgumentException()) @@ -65,11 +77,14 @@ object WebSocketMessage: enum Payload: case Heartbeat case Join( - url: String, + url: Option[String], + redirect: Option[String], // params: Map[String, String], session: String, static: Option[String], sticky: Boolean) + case Leave + case Close case Reply(status: String, response: LiveResponse) case Diff(diff: scalive.Diff) case Event(`type`: Payload.EventType, event: String, value: Map[String, String]) diff --git a/example/src/HomeLiveView.scala b/example/src/HomeLiveView.scala index 2ad5e51..7b5f984 100644 --- a/example/src/HomeLiveView.scala +++ b/example/src/HomeLiveView.scala @@ -17,8 +17,8 @@ class HomeLiveView() extends LiveView[String, Unit]: cls := "space-y-2", links.map((path, name) => li( - a( - href := path, + link.navigate( + path, cls := "block px-4 py-2 rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 font-medium transition", name ) diff --git a/zio/src/scalive/LiveRouter.scala b/zio/src/scalive/LiveRouter.scala index 450e442..a644415 100644 --- a/zio/src/scalive/LiveRouter.scala +++ b/zio/src/scalive/LiveRouter.scala @@ -1,6 +1,5 @@ package scalive -import scalive.WebSocketMessage.LiveResponse import scalive.WebSocketMessage.Meta import scalive.WebSocketMessage.Payload import zio.* @@ -75,6 +74,18 @@ class LiveChannel(private val sockets: SubscriptionRef[Map[String, Socket[?, ?]] .map(m.updated(id, _)) }.flatMap(_ => ZIO.logDebug(s"LiveView joined $id")) + def leave(id: String): UIO[Unit] = + sockets.updateZIO { m => + m.get(id) match + case Some(socket) => + for + _ <- socket.shutdown + _ <- ZIO.logDebug(s"Left LiveView $id") + yield m.removed(id) + case None => + ZIO.logWarning(s"Tried to leave LiveView $id which doesn't exist").as(m) + } + def event(id: String, value: String, meta: WebSocketMessage.Meta): UIO[Unit] = sockets.get.flatMap { m => m.get(id) match @@ -109,13 +120,16 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo Read( WebSocketFrame.text( WebSocketMessage( - meta.joinRef, - meta.messageRef, - meta.topic, - payload match + joinRef = meta.joinRef, + messageRef = payload match + case Payload.Close => meta.joinRef + case _ => meta.messageRef, + topic = meta.topic, + eventType = payload match case Payload.Diff(_) => "diff" + case Payload.Close => "phx_close" case _ => "phx_reply", - payload + payload = payload ).toJson ) ) @@ -146,21 +160,10 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo private def handleMessage(message: WebSocketMessage, liveChannel: LiveChannel) : RIO[Scope, Option[WebSocketMessage]] = message.payload match - case Payload.Heartbeat => - ZIO.succeed( - Some( - WebSocketMessage( - message.joinRef, - message.messageRef, - message.topic, - "phx_reply", - Payload.Reply("ok", LiveResponse.Empty) - ) - ) - ) - case Payload.Join(url, session, static, sticky) => + case Payload.Heartbeat => ZIO.succeed(Some(message.okReply)) + case Payload.Join(url, redirect, session, static, sticky) => ZIO - .fromEither(URL.decode(url)).flatMap(url => + .fromEither(URL.decode(url.orElse(redirect).getOrElse(???))).flatMap(url => val req = Request(url = url) liveRoutes.iterator .map(route => @@ -169,7 +172,7 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo .toOption .map(route.liveviewBuilder(_, req)) .map( - ZIO.logDebug(s"Joining live view ${route.path.toString} ${message.topic}") *> + ZIO.logDebug(s"Joining LiveView ${route.path.toString} ${message.topic}") *> liveChannel.join(message.topic, session, _, message.meta)( using route.messageCodec ) @@ -178,12 +181,17 @@ class LiveRouter(rootLayout: HtmlElement => HtmlElement, liveRoutes: List[LiveRo .collectFirst { case Some(join) => join.map(_ => None) } .getOrElse(ZIO.succeed(None)) ) + case Payload.Leave => + liveChannel + .leave(message.topic) + .as(Some(message.okReply)) case Payload.Event(_, event, _) => liveChannel .event(message.topic, event, message.meta) .map(_ => None) case Payload.Reply(_, _) => ZIO.die(new IllegalArgumentException()) case Payload.Diff(_) => ZIO.die(new IllegalArgumentException()) + case Payload.Close => ZIO.die(new IllegalArgumentException()) end match end handleMessage diff --git a/zio/src/scalive/Socket.scala b/zio/src/scalive/Socket.scala index d76c58e..4abd138 100644 --- a/zio/src/scalive/Socket.scala +++ b/zio/src/scalive/Socket.scala @@ -64,7 +64,11 @@ object Socket: yield () }.fork stop = - inbox.shutdown *> outHub.shutdown *> clientFiber.interrupt.unit *> serverFiber.interrupt.unit + outHub.publish(Payload.Close -> meta) *> + inbox.shutdown *> + outHub.shutdown *> + clientFiber.interrupt.unit *> + serverFiber.interrupt.unit outbox = ZStream.succeed( Payload.okReply(LiveResponse.InitDiff(initDiff)) -> meta