From 1a966125442ead06d7edf4139e624bf3c1a6b90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Wed, 28 May 2025 04:01:51 +0300 Subject: [PATCH] add notification for new post in thread --- app.lua | 1 + apps/api.lua | 36 ++++++++++++++ data/static/style.css | 20 ++++++-- js/thread.js | 45 +++++++++++++++++ lib/sse.lua | 59 +++++++++++++++++++++++ nginx.conf | 1 + sass/style.scss | 22 ++++++--- util.lua | 11 +++++ views/threads/new-post-notification.etlua | 10 ++++ views/threads/thread.etlua | 2 + 10 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 apps/api.lua create mode 100644 lib/sse.lua create mode 100644 views/threads/new-post-notification.etlua diff --git a/app.lua b/app.lua index 24f48ce..29ad810 100644 --- a/app.lua +++ b/app.lua @@ -54,6 +54,7 @@ app:include("apps.topics", {path = "/topics"}) app:include("apps.threads", {path = "/threads"}) app:include("apps.mod", {path = "/mod"}) app:include("apps.post", {path = "/post"}) +app:include("apps.api", {path = "/api"}) app:get("/", function(self) return {redirect_to = self:url_for("all_topics")} diff --git a/apps/api.lua b/apps/api.lua new file mode 100644 index 0000000..954f2f0 --- /dev/null +++ b/apps/api.lua @@ -0,0 +1,36 @@ +local app = require("lapis").Application() +local sse = require("lib.sse") + +local db = require("lapis.db") + +local util = require("util") + +app:get("sse_thread_updates", "/thread-updates/:thread_id", function(self) + do + local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id) + if #thread == 0 then + return {status = 404, skip_render = true} + end + end + + local now = os.time() + local stream = sse:new() + + local thread_id = self.params.thread_id + local new_posts_query = "SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1" + + while stream.active do + stream:dispatch() + local new_post = db.query(new_posts_query, thread_id, now) + if #new_post > 0 then + local url = util.get_post_url(self, new_post[1].id) + stream:enqueue(url, "new_post_url") + end + + ngx.sleep(5) + end + + return {skip_render = true} +end) + +return app diff --git a/data/static/style.css b/data/static/style.css index 1d830bc..7093288 100644 --- a/data/static/style.css +++ b/data/static/style.css @@ -1,8 +1,3 @@ -/* src: */ -@font-face { - font-family: "body-text"; - src: url("/static/fonts/DINish[slnt,wdth,wght].woff2") format("woff2"); -} @font-face { font-family: "site-title"; src: url("/static/fonts/ChicagoFLF.woff2"); @@ -520,3 +515,18 @@ ul, ol { margin: 10px 0 10px 30px; padding: 0; } + +.new-concept-notification.hidden { + display: none; +} + +.new-concept-notification { + position: fixed; + bottom: 80px; + right: 80px; + border: 2px solid black; + background-color: #81a3e6; + padding: 20px 15px; + border-radius: 4px; + box-shadow: 0 0 30px rgba(0, 0, 0, 0.25); +} diff --git a/js/thread.js b/js/thread.js index ac67c37..6f25dec 100644 --- a/js/thread.js +++ b/js/thread.js @@ -35,4 +35,49 @@ form.action = `/post/${postId}/delete` }) } + + let newPostSubscription = null; + + function hideNotification() { + const notification = document.getElementById('new-post-notification'); + notification.classList.add('hidden'); + } + + function showNewPostNotification(url) { + const notification = document.getElementById("new-post-notification"); + + notification.classList.remove("hidden"); + + document.getElementById("dismiss-new-post-button").onclick = () => { + hideNotification(); + reconnectSSE(); + } + + document.getElementById("go-to-new-post-button").href = url; + + document.getElementById("unsub-new-post-button").onclick = () => { + hideNotification() + } + } + + function reconnectSSE() { + if (newPostSubscription) newPostSubscription.close(); + + const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value; + newPostSubscription = new EventSource(threadEndpoint); + newPostSubscription.onerror = (e) => { + console.error(e); + }; + newPostSubscription.addEventListener("new_post_url", (e) => { + showNewPostNotification(e.data); + newPostSubscription.close(); + }) + } + window.addEventListener('beforeunload', () => { + if(newPostSubscription) + { + newPostSubscription.close(); + } + }); + reconnectSSE(); } diff --git a/lib/sse.lua b/lib/sse.lua new file mode 100644 index 0000000..3980956 --- /dev/null +++ b/lib/sse.lua @@ -0,0 +1,59 @@ +---@class SSE +---@field active boolean if the stream is not active, you should stop the loop. +---@field private _queue table +local sse = {} + +---Construct a new SSE object +---@return SSE +function sse:new() + ngx.header.content_type = "text/event-stream" + ngx.header.cache_control = "no-cache" + ngx.header.connection = "keep-alive" + ngx.status = ngx.HTTP_OK + ngx.flush(true) + + local obj = { + active = true, + _queue = {}, + } + + ngx.on_abort(function() + obj.active = false + end) + + return setmetatable(obj, {__index = sse}) +end + +---add data to the stream, writing on the next dispatch. +---if `event` is given, it will be the key. +---@param data string +---@param event? string +---@return boolean status +function sse:enqueue(data, event) + if not self.active then return false end + table.insert(self._queue, { + data = data, + event = event, + }) + return true +end + +---send all events since the last dispatch and flush the queue. +---call this every iteration of the loop. +function sse:dispatch() + while #self._queue > 0 do + local msg = table.remove(self._queue, 1) + if msg.event then + ngx.print("event: " .. msg.event .. "\n") + end + ngx.print("data: " .. msg.data .. "\n\n") + end + ngx.flush(true) +end + +---close the stream. +function sse:close() + self.active = false +end + +return sse diff --git a/nginx.conf b/nginx.conf index 890ef79..2b60a8c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -19,6 +19,7 @@ http { lua_code_cache ${{CODE_CACHE}}; location / { + lua_check_client_abort on; default_type text/html; content_by_lua_block { require("lapis").serve("app") diff --git a/sass/style.scss b/sass/style.scss index ad7d281..750cf4c 100644 --- a/sass/style.scss +++ b/sass/style.scss @@ -1,12 +1,5 @@ -/* src: */ - @use "sass:color"; -@font-face { - font-family: "body-text"; - src: url("/static/fonts/DINish[slnt,wdth,wght].woff2") format("woff2"); -} - @font-face { font-family: "site-title"; src: url("/static/fonts/ChicagoFLF.woff2"); @@ -536,3 +529,18 @@ ul, ol { margin: 10px 0 10px 30px; padding: 0; } + +.new-concept-notification.hidden { + display: none; +} + +.new-concept-notification { + position: fixed; + bottom: 80px; + right: 80px; + border: 2px solid black; + background-color: #81a3e6; + padding: 20px 15px; + border-radius: 4px; + box-shadow: 0 0 30px rgba(0, 0, 0, 0.25); +} diff --git a/util.lua b/util.lua index e08d67c..7c158ba 100644 --- a/util.lua +++ b/util.lua @@ -9,6 +9,7 @@ local Avatars = require("models").Avatars local Users = require("models").Users local Posts = require("models").Posts local PostHistory = require("models").PostHistory +local Threads = require("models").Threads local babycode = require("lib.babycode") @@ -103,6 +104,16 @@ function util.split_sentences(sentences, max_sentences) return util.s_split(sentences, ".", max_sentences or 2, true, false) end +---@return string +function util.get_post_url(req, post_id) + local post = Posts:find({id = post_id}) + if not post then return "" end + local thread = Threads:find({id = post.thread_id}) + if not thread then return "" end + + return req:url_for("thread", {slug = thread.slug}, {after = post_id}) .. "#post-" .. post_id +end + function util.infobox_message(msg) local sentences = util.split_sentences(msg) if #sentences == 1 then diff --git a/views/threads/new-post-notification.etlua b/views/threads/new-post-notification.etlua new file mode 100644 index 0000000..630521c --- /dev/null +++ b/views/threads/new-post-notification.etlua @@ -0,0 +1,10 @@ + diff --git a/views/threads/thread.etlua b/views/threads/thread.etlua index 8e850a4..6bddbb0 100644 --- a/views/threads/thread.etlua +++ b/views/threads/thread.etlua @@ -55,4 +55,6 @@ +"> +<% render("views.threads.new-post-notification") %>