Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.scalajs.linker.interface.ModuleSplitStyle

import scala.sys.process.*

lazy val projectVersion = "2.3.4"
lazy val projectVersion = "2.4.0"
lazy val organizationName = "ru.trett"
lazy val scala3Version = "3.7.4"
lazy val circeVersion = "0.14.15"
Expand Down
1 change: 1 addition & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion client/src/main/scala/client/Home.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ object Home:
case Failure(err) => handleError(err)
}

def render: Element = div(
def render: Element =
val settingsFetch = model.ensureSettingsLoaded()
div(
cls := "cards main-content",
settingsFetch.collectSuccess --> settingsVar.writer,
div(
onMountBind(ctx =>
refreshFeedsBus --> { page =>
Expand Down
40 changes: 40 additions & 0 deletions client/src/main/scala/client/Models.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
package client

import com.raquo.airstream.state.{StrictSignal, Var}
import com.raquo.laminar.api.L.*
import ru.trett.rss.models.*
import client.NetworkUtils.responseDecoder
import io.circe.Decoder
import io.circe.generic.semiauto.*
import scala.util.{Try, Success, Failure}

type ChannelList = List[ChannelData]
type FeedItemList = List[FeedItemData]

object Decoders:
given Decoder[UserSettings] = deriveDecoder
given Decoder[SummarySuccess] = deriveDecoder
given Decoder[SummaryError] = deriveDecoder
given Decoder[SummaryResult] = Decoder.instance { cursor =>
cursor.downField("type").as[String].flatMap {
case "success" => cursor.as[SummarySuccess]
case "error" => cursor.as[SummaryError]
case other => Left(io.circe.DecodingFailure(s"Unknown SummaryResult type: $other", cursor.history))
}
}
given Decoder[SummaryResponse] = deriveDecoder

final class Model:
import Decoders.given

val feedVar: Var[FeedItemList] = Var(List())
val channelVar: Var[ChannelList] = Var(List())
val settingsVar: Var[Option[UserSettings]] = Var(Option.empty)
Expand All @@ -15,3 +35,23 @@ final class Model:
val channelSignal: StrictSignal[ChannelList] = channelVar.signal
val settingsSignal: StrictSignal[Option[UserSettings]] = settingsVar.signal
val unreadCountSignal: StrictSignal[Int] = unreadCountVar.signal

private val settingsLoadingVar: Var[Boolean] = Var(false)

def ensureSettingsLoaded(): EventStream[Try[Option[UserSettings]]] =
if (settingsVar.now().isDefined || settingsLoadingVar.now()) then
EventStream.empty
else
settingsLoadingVar.set(true)
FetchStream
.withDecoder(responseDecoder[Option[UserSettings]])
.get("/api/user/settings")
.map {
case Success(Some(value)) => Success(value)
case Success(None) => Failure(new RuntimeException("Failed to decode settings response"))
case Failure(err) => Failure(err)
}
.map { result =>
settingsLoadingVar.set(false)
result
}
19 changes: 9 additions & 10 deletions client/src/main/scala/client/NavBar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,18 @@ object NavBar {
cls := "sticky-navbar",
ShellBar(
_.primaryTitle := "RSS Reader",
_.notificationsCount <-- unreadCountSignal.map(count =>
if (count > 0) count.toString else ""
),
_.showNotifications <-- unreadCountSignal.map(_ > 0),
_.notificationsCount <-- unreadCountSignal.combineWith(settingsSignal).map {
case (count, settings) =>
val showNotifications = settings.exists(!_.isAiMode) && count > 0
if showNotifications then count.toString else ""
},
_.showNotifications <-- unreadCountSignal.combineWith(settingsSignal).map {
case (count, settings) => settings.exists(!_.isAiMode) && count > 0
},
_.slots.profile := Avatar(_.icon := IconName.customer, idAttr := profileId),
_.slots.logo := Icon(_.name := IconName.home),
_.item(
_.icon := IconName.ai,
_.text := "Summary",
onClick.mapTo(()) --> { Router.currentPageVar.set(SummaryRoute) }
),
_.events.onProfileClick.map(item => Some(item.detail.targetRef)) --> popoverBus.writer,
_.events.onLogoClick.mapTo(()) --> { Router.currentPageVar.set(HomeRoute) },
_.events.onLogoClick.mapTo(()) --> { Router.currentPageVar.set(SummaryRoute) },
_.events.onNotificationsClick.mapTo(()) --> {
EventBus.emit(Home.markAllAsReadBus -> ())
}
Expand Down
18 changes: 15 additions & 3 deletions client/src/main/scala/client/Router.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package client

import be.doeraene.webcomponents.ui5.Text
import com.raquo
import com.raquo.airstream.state.Var
import com.raquo.laminar.api.L.*
import org.scalajs.dom
Expand All @@ -20,7 +19,8 @@ case object NotFoundRoute extends Route

object Router:

val currentPageVar: Var[Route] = Var[Route](HomeRoute)
val currentPageVar: Var[Route] = Var[Route](SummaryRoute)
private val initialRouteSetVar: Var[Boolean] = Var(false)

private def login = LoginPage.render
private def navbar = NavBar.render
Expand All @@ -38,7 +38,19 @@ object Router:
case ErrorRoute => div(Text("An error occured"))
case NotFoundRoute => div(Text("Not Found"))
},
className := "app-container"
className := "app-container",
AppState.model.settingsSignal --> { settings =>
if (!initialRouteSetVar.now() && settings.isDefined) {
val isAiMode = settings.exists(_.isAiMode)
val currentRoute = currentPageVar.now()
if (currentRoute == SummaryRoute && !isAiMode) {
currentPageVar.set(HomeRoute)
} else if (currentRoute == LoginRoute) {
currentPageVar.set(if isAiMode then SummaryRoute else HomeRoute)
}
initialRouteSetVar.set(true)
}
}
)

def appElement(): Element = div(root)
43 changes: 34 additions & 9 deletions client/src/main/scala/client/SettingsPage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,10 @@ object SettingsPage {
case Failure(err) => handleError(err)
}

given feedItemDecoder: Decoder[FeedItemData] = deriveDecoder

given channelDecoder: Decoder[ChannelData] = deriveDecoder

given settingsDecoder: Decoder[UserSettings] = deriveDecoder

given settingsEncoder: Encoder[UserSettings] = deriveEncoder
import Decoders.given
given Decoder[FeedItemData] = deriveDecoder
given Decoder[ChannelData] = deriveDecoder
given Encoder[UserSettings] = deriveEncoder

def render: Element = div(
cls := "cards main-content",
Expand All @@ -69,8 +66,9 @@ object SettingsPage {
Link(
"Return to feeds",
_.icon := IconName.`nav-back`,
_.events.onClick.mapTo(HomeRoute) --> {
Router.currentPageVar.set
_.events.onClick.mapTo(settingsSignal.now()) --> { settings =>
val isAiMode = settings.exists(_.isAiMode)
Router.currentPageVar.set(if isAiMode then SummaryRoute else HomeRoute)
},
marginBottom.px := 20
),
Expand Down Expand Up @@ -117,6 +115,33 @@ object SettingsPage {
)
)
),
div(
formBlockStyle,
marginBottom.px := 16,
Label(
"App mode",
_.forId := "app-mode-cmb",
_.showColon := true,
_.wrappingType := WrappingType.None,
paddingRight.px := 20
),
Select(
_.id := "app-mode-cmb",
_.events.onChange
.map(_.detail.selectedOption.textContent) --> settingsVar
.updater[String]((a, b) =>
a.map(x => x.copy(aiMode = Some(b == "AI Mode")))
),
Select.option(
_.selected <-- settingsSignal.map(_.exists(_.isAiMode)),
"AI Mode"
),
Select.option(
_.selected <-- settingsSignal.map(_.exists(!_.isAiMode)),
"Regular Mode"
)
)
),
div(
paddingTop.px := 10,
Button(
Expand Down
Loading