diff --git a/README.md b/README.md index 64dae49..2b834c6 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Add a webhook (Integration > Webhooks): You can embed the chat using this URL: `{server.base_url}/chat/messages` +If you use a reverse proxy, configure `{server.base_url}/ws.*` as weboscket. + ## Usage ``` diff --git a/assets/src/js/lib/chat.js b/assets/src/js/lib/chat.js index 60217f3..bfc0fc2 100644 --- a/assets/src/js/lib/chat.js +++ b/assets/src/js/lib/chat.js @@ -1,40 +1,39 @@ const isChat = () => { - return document.querySelector('#chat') !== null + return getChat() !== null } const getChat = () => { return document.querySelector('#chat') } -const updateChat = () => { - fetch(document.location.href) - .then((response) => response.text()) - .then((html) => { - const parser = new DOMParser() - const doc = parser.parseFromString(html, 'text/html') - const nextMessages = doc.querySelectorAll('.message') +const createWebSocketConnection = () => { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws' - const currentMessages = getChat().querySelectorAll('.message') - const messagesContainer = getChat().querySelector('.messages') - - nextMessages.forEach((nextMessage) => { - let add = true - - currentMessages.forEach((currentMessage) => { - if (currentMessage.id === nextMessage.id) { - add = false - } - }) - - if (add) { - messagesContainer.appendChild(nextMessage) - } - }) - - messagesContainer.scrollTo(0, messagesContainer.scrollHeight) - - window.setTimeout(updateChat, 500) - }) + return new WebSocket(`${protocol}://${window.location.hostname}:${window.location.port}/ws/chat/messages`) } -export {isChat, updateChat} + +const runChat = () => { + const ws = createWebSocketConnection() + + ws.addEventListener('close', runChat) + + ws.addEventListener('message', (event) => { + const items = JSON.parse(event.data) + + if (items == null || !items.length) { + return + } + + const container = getChat().querySelector('.messages') + + items.forEach((item) => { + const message = document.createElement('div') + message.innerHTML = item + + container.appendChild(message) + }) + }) +} + +export {isChat, runChat} diff --git a/assets/src/js/main.js b/assets/src/js/main.js index 8d75a5c..393d4ec 100644 --- a/assets/src/js/main.js +++ b/assets/src/js/main.js @@ -1,6 +1,6 @@ import '../scss/main.scss' -import {isChat, updateChat} from './lib/chat.js' +import {isChat, runChat} from './lib/chat.js' if (isChat()) { - updateChat() + runChat() } diff --git a/assets/src/scss/main.scss b/assets/src/scss/main.scss index 37ce62e..d8bbd1b 100644 --- a/assets/src/scss/main.scss +++ b/assets/src/scss/main.scss @@ -1,3 +1,5 @@ +@import "~animate.css/animate.css"; + :root { --color-owncast-user-0: #ff717b; --color-owncast-user-1: #f4e413; @@ -22,13 +24,16 @@ background: transparent; margin: 0; font-family: monospace; - height: calc(100vh - 10px); - /* border: 5px solid #254779; */ .messages { - max-height: calc(100vh - 20px); overflow: hidden; font-size: 13px; + display: flex; + align-self: end; + flex-direction: column; + position: fixed; + bottom: 10px; + width: 100%; .message { background: #000000cc; diff --git a/bin/watch.sh b/bin/watch.sh index f4ddf7a..1aa23bc 100755 --- a/bin/watch.sh +++ b/bin/watch.sh @@ -6,6 +6,7 @@ sn=owncastwh st="Owncast Webhook" while true; do + screen -X -S "$sn" quit rm -f cmd/server/rice-box.go ./node_modules/.bin/webpack screen -S "$sn" -d -m go run ./cmd/server/ diff --git a/cmd/server/server.go b/cmd/server/server.go index 9fcb48f..69fa0fd 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "text/template" + "time" rice "github.com/GeertJohan/go.rice" "github.com/go-playground/validator" @@ -14,6 +15,7 @@ import ( "github.com/labstack/echo/v4/middleware" "gitnet.fr/deblan/owncast-webhook/internal/client/twitch" "gitnet.fr/deblan/owncast-webhook/internal/config" + "gitnet.fr/deblan/owncast-webhook/internal/store" "gitnet.fr/deblan/owncast-webhook/internal/web/router" ) @@ -43,6 +45,15 @@ func main() { e.Use(middleware.Logger()) router.RegisterControllers(e) + if conf.Test.Enable { + go func() { + for { + store.GetMessageStore().Add(store.TestMessage{}) + time.Sleep(1 * time.Second) + } + }() + } + if conf.Twitch.Enable { twitch.IrcClient() } diff --git a/config.ini.example b/config.ini.example index 9be2b6d..7885e57 100644 --- a/config.ini.example +++ b/config.ini.example @@ -10,3 +10,7 @@ base_url = "https://live.example.com" [twitch] enable = false channel = "username" + +; Add fake messages to test +[test] +enable = false diff --git a/go.mod b/go.mod index a7fcd5b..e135418 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 51b1541..225c6a1 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= diff --git a/internal/config/config.go b/internal/config/config.go index 9b020a2..e3d07c3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,9 @@ type Config struct { Owncast struct { BaseUrl string } + Test struct { + Enable bool + } Twitch struct { Enable bool Channel string @@ -45,6 +48,7 @@ func (c *Config) Load(file string) { config.Server.WebhookSecret = cfg.Section("server").Key("webhook_secret").String() config.Owncast.BaseUrl = cfg.Section("owncast").Key("base_url").String() + config.Test.Enable = cfg.Section("test").Key("enable").MustBool(false) config.Twitch.Enable = cfg.Section("twitch").Key("enable").MustBool(false) config.Twitch.Channel = cfg.Section("twitch").Key("channel").String() diff --git a/internal/store/message.go b/internal/store/message.go index 385a8c1..a8b106d 100644 --- a/internal/store/message.go +++ b/internal/store/message.go @@ -7,6 +7,7 @@ var messageStore *MessageStore const ( MessageOriginOwncast MessageOrigin = iota MessageOriginTwitch + MessageOriginTest ) type MessageStore struct { @@ -21,6 +22,10 @@ func (s *MessageStore) All() []MessageInterface { return s.messages } +func (s *MessageStore) Clear() { + s.messages = []MessageInterface{} +} + func GetMessageStore() *MessageStore { if messageStore == nil { messageStore = new(MessageStore) diff --git a/internal/store/test_message.go b/internal/store/test_message.go new file mode 100644 index 0000000..8b66af0 --- /dev/null +++ b/internal/store/test_message.go @@ -0,0 +1,32 @@ +package store + +import ( + "fmt" + "math/rand" + + "github.com/gempir/go-twitch-irc/v4" +) + +type TestMessage struct { + Message twitch.PrivateMessage +} + +func (o TestMessage) ID() string { + return fmt.Sprintf("%f", rand.Float64()) +} + +func (o TestMessage) Visible() bool { + return true +} + +func (o TestMessage) Origin() MessageOrigin { + return MessageOriginTest +} + +func (o TestMessage) Author() string { + return fmt.Sprintf("%f", rand.Float64()) +} + +func (o TestMessage) Content() string { + return fmt.Sprintf("%f", rand.Float64()) +} diff --git a/internal/web/controller/chat/controller.go b/internal/web/controller/chat/controller.go index ab42367..eee0731 100644 --- a/internal/web/controller/chat/controller.go +++ b/internal/web/controller/chat/controller.go @@ -1,8 +1,10 @@ package chat import ( - "fmt" + "bytes" + "encoding/json" + "github.com/gorilla/websocket" "github.com/labstack/echo/v4" "gitnet.fr/deblan/owncast-webhook/assets" "gitnet.fr/deblan/owncast-webhook/internal/store" @@ -11,6 +13,10 @@ import ( . "maragu.dev/gomponents/html" ) +var ( + upgrader = websocket.Upgrader{} +) + type Controller struct { } @@ -18,51 +24,50 @@ func New(e *echo.Echo) *Controller { c := Controller{} e.GET("/chat/messages", c.Messages) + e.GET("/ws/chat/messages", c.WebsocketMessages) return &c } -func (ctrl *Controller) Messages(c echo.Context) error { - createMessage := func(message store.MessageInterface) Node { - var containerStyle Node - var userStyle Node - var originIcon Node +func (ctrl *Controller) WebsocketMessages(c echo.Context) error { + ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) - switch message.Origin() { - case store.MessageOriginOwncast: - msg := message.(store.OwncastMessage) - - containerStyle = StyleAttr(fmt.Sprintf( - "border-color: var(--theme-color-users-%d)", - msg.WebhookMessage.User.DisplayColor, - )) - - userStyle = StyleAttr(fmt.Sprintf( - "color: var(--theme-color-users-%d)", - msg.WebhookMessage.User.DisplayColor, - )) - - originIcon = Img(Src(assets.Asset("dist/img/owncast.png")), Class("message-origin")) - case store.MessageOriginTwitch: - originIcon = Img(Src(assets.Asset("dist/img/twitch.png")), Class("message-origin")) - } - - return Div( - Class("message"), - ID(message.ID()), - containerStyle, - Div( - Class("message-user"), - userStyle, - Group([]Node{originIcon, Text(message.Author())}), - ), - Div( - Class("message-body"), - Raw(message.Content()), - ), - ) + if err != nil { + return err } + defer ws.Close() + + storage := store.GetMessageStore() + + for { + messages := storage.All() + + if len(messages) == 0 { + continue + } + + storage.Clear() + + var items []string + + for _, message := range messages { + var buff bytes.Buffer + msg := CreateMessageView(message) + msg.Render(&buff) + + items = append(items, buff.String()) + } + + j, _ := json.Marshal(items) + + ws.WriteMessage(websocket.TextMessage, j) + } + + return nil +} + +func (ctrl *Controller) Messages(c echo.Context) error { page := HTML5(HTML5Props{ Title: "Chat", Language: "fr", @@ -74,7 +79,7 @@ func (ctrl *Controller) Messages(c echo.Context) error { ID("chat"), Div( Class("messages"), - Map(store.GetMessageStore().All(), createMessage), + Map(store.GetMessageStore().All(), CreateMessageView), ), Group(assets.EntrypointJs("main")), }, diff --git a/internal/web/controller/chat/utils.go b/internal/web/controller/chat/utils.go new file mode 100644 index 0000000..63b62c3 --- /dev/null +++ b/internal/web/controller/chat/utils.go @@ -0,0 +1,50 @@ +package chat + +import ( + "fmt" + + "gitnet.fr/deblan/owncast-webhook/assets" + "gitnet.fr/deblan/owncast-webhook/internal/store" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func CreateMessageView(message store.MessageInterface) Node { + var containerStyle Node + var userStyle Node + var originIcon Node + + switch message.Origin() { + case store.MessageOriginOwncast: + msg := message.(store.OwncastMessage) + + containerStyle = StyleAttr(fmt.Sprintf( + "border-color: var(--theme-color-users-%d)", + msg.WebhookMessage.User.DisplayColor, + )) + + userStyle = StyleAttr(fmt.Sprintf( + "color: var(--theme-color-users-%d)", + msg.WebhookMessage.User.DisplayColor, + )) + + originIcon = Img(Src(assets.Asset("dist/img/owncast.png")), Class("message-origin")) + case store.MessageOriginTwitch: + originIcon = Img(Src(assets.Asset("dist/img/twitch.png")), Class("message-origin")) + } + + return Div( + Class("message animate__animated animate__fadeInRight"), + ID(message.ID()), + containerStyle, + Div( + Class("message-user"), + userStyle, + Group([]Node{originIcon, Text(message.Author())}), + ), + Div( + Class("message-body"), + Raw(message.Content()), + ), + ) +} diff --git a/package-lock.json b/package-lock.json index 21e542c..9037682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@symfony/webpack-encore": "github:symfony/webpack-encore", + "animate.css": "^4.1.1", "raw-loader": "^4.0.2" }, "devDependencies": { @@ -2734,6 +2735,12 @@ "ajv": "^6.9.1" } }, + "node_modules/animate.css": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", + "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/package.json b/package.json index 5a26d4e..ed07c0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@symfony/webpack-encore": "github:symfony/webpack-encore", + "animate.css": "^4.1.1", "raw-loader": "^4.0.2" }, "devDependencies": {