Skip to content

Commit 841e89e

Browse files
authored
Chat app (#8)
1 parent 2acd5f9 commit 841e89e

File tree

21 files changed

+810
-0
lines changed

21 files changed

+810
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ logs
1616
*.iml
1717
metals.sbt
1818

19+
# Auto-generated by `sbt appJS/fastOptJS`.
20+
**/js/static/main.js
21+
**/js/static/main.js.map
22+
1923
.DS_Store

chat-app/.scalafmt.conf

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# DO NOT EDIT THIS FILE WITHOUT CONSENT OF TEAM.
2+
version = 3.9.5
3+
runner.dialect = scala3
4+
5+
maxColumn = 120
6+
project.git = true
7+
8+
indent.main = 2
9+
indent.callSite = 2
10+
indent.ctorSite = 2
11+
indent.ctrlSite = 2
12+
indent.defnSite = 2
13+
indent.matchSite = 2
14+
indent.significant = 2
15+
16+
docstrings.style = keep
17+
assumeStandardLibraryStripMargin = true
18+
align.stripMargin = true
19+
20+
align.preset = more
21+
22+
align.tokens = [
23+
"=>", "->", "<-", ":=", "//", "%", "%%", "%%%", "+=",
24+
{
25+
"code" = "=",
26+
"owners" = [{ regex = ".*" }]
27+
},
28+
{
29+
"code" = ":",
30+
"owners" = [{ regex = ".*" }]
31+
},
32+
{
33+
"code" = "=>"
34+
"owners" = [{ regex = "(Importee.Rename|Case)" }]
35+
}
36+
]
37+

chat-app/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Chat App
2+
3+
## Stack
4+
5+
- Postgres
6+
- ScalaSql
7+
- Scalatags
8+
- Scala.js
9+
10+
## Running the application
11+
12+
1. Run the client with `sbt "~appJS/fastOptJS"` to keep the client files up to date with the changes you make to `js (appJS)` project.
13+
2. TODO: Figure out how to run the server from command line. `sbt runMain com.rtjvm.chat.backend.Server` or `sbt run` does not launch the server. **For now, you should be able to run it from IntelliJ**. Go to `Server.scala` and run the file.
14+
3. Chat application should be accessible at http://localhost:8080/static/index.html
15+
16+
## Project Info
17+
18+
- The project is implemented as per the chapters in the book except it is in a finished state, and does not instantly show the progression in the book. For instance, the current client does not do form submit as described in the initial pages of the chapter in the book. But uses REST APIs instead.
19+
- All outgoing responses use `Message` object to represent a message.
20+
- API responses (currently only `/chat`) use `ChatResponse` object to represent the response, which uses `Message` object to represent a message.
21+
- All websocket messages use `Seq[Message]` to push messages to the client.
22+
- Followed the book even though inefficient to push all messages to the client at once; even when sending a single chat message.
23+
24+
## Differences:
25+
26+
- The project does not POST to the root path `/` to send a chat message instead POSTs to the `/chat` endpoint with a JSON body.
27+
28+
## Exercises
29+
30+
**Add HTML input to let the use filter chat by username.**
31+
32+
See endpoint `@cask.getJson("/messages/:searchTerm")` in `Server.scala`.
33+
34+
**Synchronize access to open connections using `synchronized`**
35+
36+
Not using `synchronized` but using a `ConcurrentHashMap`.
37+
38+
**The online examples so far provide a simple test suite, that uses `String.contains` .... Use the Jsoup library we saw Chapter 11: Scraping Websites to make ... tag**
39+
40+
Not Implemented! TBD!
41+
42+
**Keep track each message's send time and date in the database, and display it in the user interface**
43+
44+
See `Postgres#initDb`
45+
46+
**Add the ability to reply directly to existing chat messages, ... nested arbitrarily deeply to form a tree-shaped "threaded" discussion**
47+
48+
- Look for `parent` on the server side
49+
- For client side, see `Main.scala` (`Main#messageList`)
50+
51+
**One limitation of the current push-update mechanism is that it can only updates to
52+
browsers connected to the same webserver. Make use of Postgres's LISTEN/NOTIFY feature ... register callbacks on these events**
53+
54+
See `PostgresListener`

chat-app/build.sbt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
ThisBuild / scalaVersion := "3.7.0"
2+
ThisBuild / version := "0.1.0-SNAPSHOT"
3+
ThisBuild / organization := "com.rtjvm"
4+
5+
lazy val chatApp =
6+
project
7+
.in(file("."))
8+
.aggregate(app.js, app.jvm)
9+
10+
lazy val app =
11+
crossProject(JSPlatform, JVMPlatform)
12+
.in(file("."))
13+
.settings(
14+
name := "chat-app",
15+
libraryDependencies ++=
16+
"com.lihaoyi" %%% "upickle" % "4.1.0" ::
17+
"com.lihaoyi" %%% "scalatags" % "0.12.0" ::
18+
"io.circe" %%% "circe-core" % "0.14.6" ::
19+
"io.circe" %%% "circe-generic" % "0.14.6" ::
20+
"io.circe" %%% "circe-parser" % "0.14.6" ::
21+
Nil
22+
)
23+
.jsSettings(
24+
Compile / fastOptJS / artifactPath := baseDirectory.value / "static/main.js",
25+
scalaJSUseMainModuleInitializer := true,
26+
libraryDependencies ++=
27+
"org.scala-js" %%% "scalajs-dom" % "2.8.0" ::
28+
"com.lihaoyi" %%% "scalatags" % "0.12.0" ::
29+
Nil
30+
)
31+
.jvmSettings(
32+
libraryDependencies ++=
33+
"com.lihaoyi" %% "cask" % "0.10.2" ::
34+
"com.lihaoyi" %% "scalasql" % "0.1.19" ::
35+
"com.lihaoyi" %% "os-lib" % "0.11.4" ::
36+
"com.zaxxer" % "HikariCP" % "5.1.0" ::
37+
"io.zonky.test" % "embedded-postgres" % "2.1.0" ::
38+
"com.impossibl.pgjdbc-ng" % "pgjdbc-ng" % "0.8.9" ::
39+
Nil
40+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.rtjvm.chat.frontend
2+
3+
import com.rtjvm.chat.shared.models.Message
4+
import org.scalajs.dom
5+
import org.scalajs.dom.html.Div
6+
import scalatags.JsDom
7+
import scalatags.JsDom.all.*
8+
9+
import scala.scalajs.js.Date
10+
11+
object DomUtils {
12+
def getByIdAs[T <: dom.Element](id: String): T =
13+
val e = dom.document.getElementById(id)
14+
if e != null then e.asInstanceOf[T]
15+
else null.asInstanceOf[T]
16+
17+
def fragFor(m: Message): JsDom.TypedTag[Div] =
18+
div(cls := "message")(
19+
span(cls := "id")(s"(${m.id})"),
20+
span(cls := "sender")(s"${m.sender}:"),
21+
span(cls := "msg")(m.msg),
22+
span(cls := "timestamp")(new Date(m.timestamp).toISOString())
23+
)
24+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.rtjvm.chat.frontend
2+
3+
import com.rtjvm.chat.frontend.DomUtils.*
4+
import com.rtjvm.chat.shared.models.*
5+
import org.scalajs.dom
6+
import org.scalajs.dom.html.Div
7+
import org.scalajs.dom.{HttpMethod, WebSocket, html}
8+
import scalatags.JsDom
9+
import scalatags.JsDom.all._
10+
import upickle.default.*
11+
12+
import scala.scalajs.js
13+
14+
object Main extends App {
15+
private val ApiServer = "http://localhost:8080"
16+
private val chatForm = getByIdAs[html.Form]("chat-form")
17+
private val senderInput = getByIdAs[html.Input]("sender")
18+
private val msgInput = getByIdAs[html.Input]("message")
19+
private val replyToInput = getByIdAs[html.Input]("reply-to")
20+
private val sendBtn = getByIdAs[html.Button]("send")
21+
private val messagesDiv = getByIdAs[html.Div]("messages")
22+
private val errorDiv = dom.document.getElementById("error")
23+
24+
assert(chatForm != null, "Chat form not found")
25+
assert(senderInput != null, "Name input not found")
26+
assert(msgInput != null, "Message input not found")
27+
assert(replyToInput != null, "Reply-To input not found")
28+
assert(sendBtn != null, "Greet button not found")
29+
assert(errorDiv != null, "Greeting div not found")
30+
31+
private val statusBar = new StatusBar(errorDiv.asInstanceOf[dom.html.Element])
32+
private val socket = new WebSocket("ws://localhost:8080/subscribe")
33+
34+
dom.document
35+
.getElementById("search-input")
36+
.addEventListener(
37+
"keydown",
38+
(event: dom.KeyboardEvent) =>
39+
if (event.key == "Enter") {
40+
val searchInput = event.target.asInstanceOf[dom.HTMLInputElement]
41+
val searchTerm = searchInput.value.trim
42+
43+
val requestInit = new dom.RequestInit {
44+
method = HttpMethod.GET
45+
headers = js.Dictionary("Content-Type" -> "application/json")
46+
}
47+
48+
dom
49+
.fetch(s"$ApiServer/messages/$searchTerm", requestInit)
50+
.`then` { response =>
51+
response
52+
.text()
53+
.`then` { text =>
54+
try {
55+
val messages = read[Seq[Message]](text)
56+
println(s"Received messages: $messages")
57+
if searchTerm.isBlank then renderMessages(messages)
58+
else renderFilteredMsgs(messages)
59+
} catch {
60+
case e: Exception =>
61+
System.err.println(s"Error parsing response: ${e.getMessage}")
62+
statusBar.setError("Error sending message")
63+
}
64+
}
65+
}
66+
}
67+
)
68+
69+
sendBtn.addEventListener(
70+
"click",
71+
_ => {
72+
if (senderInput.value.trim.isEmpty) {
73+
statusBar.setError("Please enter your name")
74+
senderInput.focus()
75+
} else if (msgInput.value.trim.isEmpty) {
76+
statusBar.setError("Please enter a message")
77+
msgInput.focus()
78+
} else {
79+
statusBar.clear()
80+
val message =
81+
NewMessage(
82+
sender = senderInput.value,
83+
msg = msgInput.value,
84+
parent = replyToInput.value.toLongOption
85+
)
86+
87+
val requestInit = new dom.RequestInit {
88+
method = HttpMethod.POST
89+
body = writeJs(message).toString()
90+
headers = js.Dictionary("Content-Type" -> "application/json")
91+
}
92+
93+
dom
94+
.fetch(s"$ApiServer/chat", requestInit)
95+
.`then` { response =>
96+
response
97+
.text()
98+
.`then` { (text: String) =>
99+
try {
100+
val messages = read[ChatResponse](text).messages
101+
renderMessages(messages)
102+
103+
msgInput.value = ""
104+
msgInput.focus()
105+
} catch {
106+
case e: Exception =>
107+
System.err.println(s"Error parsing response: ${e.getMessage}")
108+
statusBar.setError("Error sending message")
109+
}
110+
}
111+
}
112+
.`catch` { err =>
113+
System.err.println(s"Error sending message: $err")
114+
statusBar.setError("Error sending message")
115+
}
116+
}
117+
}
118+
)
119+
120+
socket.onopen = { (event: dom.Event) =>
121+
try {
122+
println("WebSocket connection established")
123+
statusBar.setInfo("Connected to chat server")
124+
} catch {
125+
case e: Exception =>
126+
System.err.println(s"Error processing WebSocket message: $e")
127+
statusBar.setError(s"Error: ${e.getMessage}")
128+
}
129+
}
130+
131+
socket.onmessage = { (event: dom.MessageEvent) =>
132+
try {
133+
val messages = read[Seq[Message]](event.data.toString)
134+
renderMessages(messages)
135+
} catch {
136+
case e: Exception =>
137+
System.err.println(s"Error processing WebSocket message: ${e.getMessage}")
138+
statusBar.setError(s"Error: ${e.getMessage}")
139+
}
140+
}
141+
142+
socket.onerror = { (event: dom.Event) =>
143+
println(s"WebSocket error: ${event.toString}")
144+
}
145+
146+
private def renderMessages(messages: Seq[Message]): Unit = {
147+
messagesDiv.innerHTML = messageList(messages).map(_.toString).mkString
148+
}
149+
150+
private def messageList(messages: Seq[Message]) = {
151+
val msgMap = messages.groupBy(_.parent)
152+
153+
def messageListFrag(parent: Option[Long] = None): Seq[JsDom.TypedTag[Div]] =
154+
for (msg <- msgMap.getOrElse(parent, Nil))
155+
yield div(
156+
fragFor(msg),
157+
div(paddingLeft := 15)(messageListFrag(Some(msg.id)))
158+
)
159+
160+
messageListFrag(None)
161+
}
162+
163+
private def renderFilteredMsgs(messages: Seq[Message]): Unit = {
164+
def formatMsgs: Seq[JsDom.TypedTag[Div]] =
165+
for (msg <- messages) yield fragFor(msg)
166+
167+
messagesDiv.innerHTML = formatMsgs.map(_.toString).mkString
168+
}
169+
170+
println("Hello from Scala.js frontend!")
171+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.rtjvm.chat.frontend
2+
3+
import org.scalajs.dom
4+
5+
class StatusBar(val el: dom.html.Element) {
6+
def setError(msg: String): Unit = {
7+
el.textContent = msg
8+
el.style.color = "red"
9+
}
10+
11+
def setInfo(msg: String): Unit = {
12+
el.textContent = msg
13+
el.style.color = "black"
14+
}
15+
16+
def clear(): Unit = {
17+
el.textContent = ""
18+
el.style.color = "black"
19+
}
20+
}

0 commit comments

Comments
 (0)