diff --git a/CHANGELOG.md b/CHANGELOG.md index bd56254..664aae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ [Unreleased] +## v1.1.0 - 2025-09-07 + +### Added + +- feat(chat): use websocket to update messages +- feat(chat): add css animation + ## v1.0.1 - 2025-08-31 ### Fixed 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..38a6666 100644 --- a/assets/src/js/lib/chat.js +++ b/assets/src/js/lib/chat.js @@ -1,40 +1,51 @@ 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 isInViewport = (element) => { + const rect = element.getBoundingClientRect() + + return (rect.top - 90) > 0 +} + +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) + }) + + getChat().querySelectorAll('.message').forEach((message) => { + if (!isInViewport(message)) { + message.classList.toggle('animate__fadeInUp', false) + message.classList.toggle('animate__fadeOutUp', true) + } + }) + }) +} + +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..7110344 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; @@ -16,19 +18,24 @@ --theme-color-users-5: var(--color-owncast-user-5); --theme-color-users-6: var(--color-owncast-user-6); --theme-color-users-7: var(--color-owncast-user-7); + + --animate-duration: 500ms; } #chat { 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..9e16d5c 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -14,6 +14,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/test" "gitnet.fr/deblan/owncast-webhook/internal/web/router" ) @@ -43,6 +44,10 @@ func main() { e.Use(middleware.Logger()) router.RegisterControllers(e) + if conf.Test.Enable { + test.GenerateFakeMessages() + } + 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..12784d9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/GeertJohan/go.rice v1.0.3 github.com/gempir/go-twitch-irc/v4 v4.2.0 github.com/go-playground/validator v9.31.0+incompatible + github.com/gorilla/websocket v1.5.3 github.com/labstack/echo/v4 v4.12.0 gopkg.in/ini.v1 v1.67.0 maragu.dev/gomponents v1.2.0 @@ -13,6 +14,7 @@ require ( require ( github.com/daaku/go.zipexe v1.0.2 // indirect + github.com/go-faker/faker/v4 v4.6.1 // indirect 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 @@ -26,7 +28,7 @@ require ( golang.org/x/crypto v0.27.0 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index 51b1541..bdbad59 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gempir/go-twitch-irc/v4 v4.2.0 h1:OCeff+1aH4CZIOxgKOJ8dQjh+1ppC6sLWrXOcpGZyq4= github.com/gempir/go-twitch-irc/v4 v4.2.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg= +github.com/go-faker/faker/v4 v4.6.1 h1:xUyVpAjEtB04l6XFY0V/29oR332rOSPWV4lU8RwDt4k= +github.com/go-faker/faker/v4 v4.6.1/go.mod h1:arSdxNCSt7mOhdk8tEolvHeIJ7eX4OX80wXjKKvkKBY= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -16,6 +18,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= @@ -46,8 +50,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 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..1cdfe12 --- /dev/null +++ b/internal/store/test_message.go @@ -0,0 +1,30 @@ +package store + +import ( + "github.com/gempir/go-twitch-irc/v4" + "github.com/go-faker/faker/v4" +) + +type TestMessage struct { + Message twitch.PrivateMessage +} + +func (o TestMessage) ID() string { + return faker.Sentence() +} + +func (o TestMessage) Visible() bool { + return true +} + +func (o TestMessage) Origin() MessageOrigin { + return MessageOriginTest +} + +func (o TestMessage) Author() string { + return faker.Username() +} + +func (o TestMessage) Content() string { + return faker.Sentence() +} diff --git a/internal/test/fake.go b/internal/test/fake.go new file mode 100644 index 0000000..90fbd12 --- /dev/null +++ b/internal/test/fake.go @@ -0,0 +1,17 @@ +package test + +import ( + "math/rand" + "time" + + "gitnet.fr/deblan/owncast-webhook/internal/store" +) + +func GenerateFakeMessages() { + go func() { + for { + store.GetMessageStore().Add(store.TestMessage{}) + time.Sleep(time.Duration(rand.Intn(3000-100)+100) * time.Millisecond) + } + }() +} diff --git a/internal/web/controller/chat/controller.go b/internal/web/controller/chat/controller.go index ab42367..7bc963f 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", @@ -72,10 +77,7 @@ func (ctrl *Controller) Messages(c echo.Context) error { }, Body: []Node{ ID("chat"), - Div( - Class("messages"), - Map(store.GetMessageStore().All(), createMessage), - ), + Div(Class("messages")), 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..7b6ef4a --- /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__fadeInUp"), + 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": {