Compare commits
3 Commits
68d109f428
...
22526c953e
Author | SHA1 | Date | |
---|---|---|---|
22526c953e | |||
bd1ba6c087 | |||
1e23959e52 |
9
app.lua
9
app.lua
@ -1,5 +1,6 @@
|
||||
local lapis = require("lapis")
|
||||
local date = require("date")
|
||||
local models = require("models")
|
||||
local app = lapis.Application()
|
||||
local constants = require("constants")
|
||||
local babycode = require("lib.babycode")
|
||||
@ -48,6 +49,14 @@ local function inject_methods(req)
|
||||
return babycode.to_html(bb, html_escape)
|
||||
end
|
||||
|
||||
req.get_thread_by_id = function(_, id)
|
||||
return models.Threads:find({id = id})
|
||||
end
|
||||
|
||||
req.get_post_url = function(_, id)
|
||||
return util.get_post_url(_, id)
|
||||
end
|
||||
|
||||
util.pop_infobox(req)
|
||||
end
|
||||
|
||||
|
@ -9,6 +9,7 @@ local models = require("models")
|
||||
local Topics = models.Topics
|
||||
local Threads = models.Threads
|
||||
local Posts = models.Posts
|
||||
local Subscriptions = models.Subscriptions
|
||||
|
||||
local POSTS_PER_PAGE = 10
|
||||
|
||||
@ -98,6 +99,17 @@ app:get("thread", "/:slug", function(self)
|
||||
self.me = util.get_logged_in_user_or_transient(self)
|
||||
self.posts = posts
|
||||
|
||||
if self.me:is_logged_in() then
|
||||
self.is_subscribed = false
|
||||
local subscription = Subscriptions:find({user_id = self.me.id, thread_id = thread.id})
|
||||
if subscription then
|
||||
self.is_subscribed = true
|
||||
if posts[#posts].created_at > subscription.last_seen then
|
||||
subscription:update({last_seen = os.time()})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self.page_title = thread.title
|
||||
|
||||
return {render = "threads.thread"}
|
||||
@ -133,6 +145,10 @@ app:post("thread", "/:slug", function(self)
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||
end
|
||||
|
||||
if self.params.subscribe == "on" then
|
||||
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = os.time()})
|
||||
end
|
||||
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||
end)
|
||||
|
||||
@ -206,4 +222,50 @@ app:post("thread_move", "/:slug/move", function(self)
|
||||
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||
end)
|
||||
|
||||
app:post("thread_subscribe", "/:slug/subscribe", function(self)
|
||||
local user = util.get_logged_in_user(self)
|
||||
if not user then
|
||||
return {status = 403}
|
||||
end
|
||||
local thread = Threads:find({slug = self.params.slug})
|
||||
if not thread then
|
||||
return {status = 404}
|
||||
end
|
||||
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
|
||||
if self.params.subscribe == "subscribe" then
|
||||
local now = os.time()
|
||||
if subscription then
|
||||
subscription:delete()
|
||||
end
|
||||
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = now})
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
elseif self.params.subscribe == "unsubscribe" then
|
||||
if not subscription then
|
||||
return {status = 404}
|
||||
end
|
||||
subscription:delete()
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
elseif self.params.subscribe == "read" then
|
||||
if not subscription then
|
||||
return {status = 404}
|
||||
end
|
||||
subscription:update({last_seen = os.time()})
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
end
|
||||
|
||||
return {status = 400}
|
||||
end)
|
||||
|
||||
return app
|
||||
|
@ -14,6 +14,7 @@ local models = require("models")
|
||||
local Users = models.Users
|
||||
local Sessions = models.Sessions
|
||||
local Avatars = models.Avatars
|
||||
local Subscriptions = models.Subscriptions
|
||||
|
||||
local function authenticate_user(user, password)
|
||||
return auth.verify(password, user.password_hash)
|
||||
@ -267,6 +268,11 @@ app:post("user_settings", "/:username/settings", function(self)
|
||||
local status = self.params.status:sub(1, 100)
|
||||
local original_sig = self.params.signature or ""
|
||||
local rendered_sig = babycode.to_html(original_sig, html_escape)
|
||||
if self.params.subscribe_by_default == "on" then
|
||||
self.session.subscribe_by_default = true
|
||||
else
|
||||
self.session.subscribe_by_default = false
|
||||
end
|
||||
|
||||
target_user:update({
|
||||
status = status,
|
||||
@ -277,6 +283,97 @@ app:post("user_settings", "/:username/settings", function(self)
|
||||
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||
end)
|
||||
|
||||
app:get("user_inbox", "/:username/inbox", function(self)
|
||||
local me = util.get_logged_in_user(self)
|
||||
if me == nil then
|
||||
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||
return {redirect_to = self:url_for("user_login")}
|
||||
end
|
||||
local target_user = Users:find({username = self.params.username})
|
||||
if me.id ~= target_user.id then
|
||||
return {redirect_to = self:url_for("user_inbox", {username = me.username})}
|
||||
end
|
||||
self.me = target_user
|
||||
self.page_title = "inbox"
|
||||
self.new_posts = {}
|
||||
local subscription = Subscriptions:find({user_id = me.id})
|
||||
if subscription then
|
||||
local q = [[
|
||||
WITH thread_metadata AS (
|
||||
SELECT
|
||||
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
|
||||
FROM
|
||||
posts
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
GROUP BY posts.thread_id
|
||||
)
|
||||
|
||||
SELECT
|
||||
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
|
||||
|
||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
|
||||
FROM
|
||||
thread_metadata tm
|
||||
JOIN
|
||||
posts ON posts.thread_id = tm.thread_id
|
||||
JOIN
|
||||
post_history ON posts.current_revision_id = post_history.id
|
||||
JOIN
|
||||
users ON posts.user_id = users.id
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
avatars ON users.avatar_id = avatars.id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE
|
||||
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
ORDER BY
|
||||
tm.newest_post_time DESC, posts.created_at ASC]]
|
||||
local new_posts_raw = db.query(q, me.id, me.id)
|
||||
local threads = {}
|
||||
local current_thread_id = nil
|
||||
local current_thread_group = nil
|
||||
self.total_unreads_count = 0
|
||||
for _, row in ipairs(new_posts_raw) do
|
||||
if row.thread_id ~= current_thread_id then
|
||||
current_thread_group = {
|
||||
thread_id = row.thread_id,
|
||||
thread_title = row.thread_title,
|
||||
unread_count = row.unread_count,
|
||||
thread_slug = row.thread_slug,
|
||||
newest_post_time = row.newest_post_time,
|
||||
posts = {}
|
||||
}
|
||||
self.total_unreads_count = self.total_unreads_count + row.unread_count
|
||||
table.insert(threads, current_thread_group)
|
||||
current_thread_id = row.thread_id
|
||||
end
|
||||
---@diagnostic disable-next-line: need-check-nil
|
||||
table.insert(current_thread_group.posts, {
|
||||
id = row.id,
|
||||
created_at = row.created_at,
|
||||
content = row.content,
|
||||
edited_at = row.edited_at,
|
||||
username = row.username,
|
||||
status = row.status,
|
||||
avatar_path = row.avatar_path,
|
||||
thread_id = row.thread_id,
|
||||
user_id = row.user_id,
|
||||
original_markup = row.original_markup,
|
||||
signature_rendered = row.signature_rendered,
|
||||
})
|
||||
end
|
||||
|
||||
self.new_posts = threads
|
||||
end
|
||||
return {render = "user.inbox"}
|
||||
end)
|
||||
|
||||
app:get("user_login", "/login", function(self)
|
||||
if self.session.session_key then
|
||||
local user = util.get_logged_in_user(self)
|
||||
|
@ -634,3 +634,52 @@ ul, ol {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgb(159.0271653543, 162.0727712915, 172.9728346457);
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
@ -22,9 +22,27 @@ function activateSelfDeactivateSibs(button) {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// tabs
|
||||
document.querySelectorAll(".tab-button").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
activateSelfDeactivateSibs(button);
|
||||
});
|
||||
});
|
||||
|
||||
// accordions
|
||||
const accordions = document.querySelectorAll(".accordion");
|
||||
accordions.forEach(accordion => {
|
||||
const header = accordion.querySelector(".accordion-header");
|
||||
const toggleButton = header.querySelector(".accordion-toggle");
|
||||
const content = accordion.querySelector(".accordion-content");
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
accordion.classList.toggle("hidden");
|
||||
content.classList.toggle("hidden");
|
||||
toggleButton.textContent = content.classList.contains("hidden") ? "►" : "▼"
|
||||
}
|
||||
|
||||
toggleButton.addEventListener("click", toggle);
|
||||
});
|
||||
});
|
@ -102,4 +102,15 @@ return {
|
||||
|
||||
db.query("CREATE INDEX idx_rate_limit_user_method ON api_rate_limits (user_id, method)")
|
||||
end,
|
||||
|
||||
[13] = function ()
|
||||
schema.create_table("subscriptions", {
|
||||
{"id", types.integer{primary_key = true}},
|
||||
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
|
||||
{"last_seen", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)) NOT NULL"},
|
||||
})
|
||||
|
||||
db.query("CREATE INDEX idx_subscription_user_thread ON subscriptions (user_id, thread_id)")
|
||||
end,
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ local ret = {
|
||||
PostHistory = Model:extend("post_history"),
|
||||
Sessions = Model:extend("sessions"),
|
||||
Avatars = Model:extend("avatars"),
|
||||
Subscriptions = Model:extend("subscriptions"),
|
||||
}
|
||||
|
||||
return ret
|
||||
|
@ -46,6 +46,7 @@ $lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
||||
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
|
||||
$button_color: color.adjust($accent_color, $hue: 90);
|
||||
$button_color2: color.adjust($accent_color, $hue: 180);
|
||||
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
|
||||
|
||||
%button-base {
|
||||
cursor: default;
|
||||
@ -643,3 +644,52 @@ ul, ol {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $accordion_color;
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
7
util.lua
7
util.lua
@ -105,13 +105,16 @@ function util.split_sentences(sentences, max_sentences)
|
||||
end
|
||||
|
||||
---@return string
|
||||
function util.get_post_url(req, post_id)
|
||||
function util.get_post_url(req, post_id, hash)
|
||||
hash = hash ~= false
|
||||
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
|
||||
local url = req:url_for("thread", {slug = thread.slug}, {after = post_id})
|
||||
if not hash then return url end
|
||||
return url .. "#post-" .. post_id
|
||||
end
|
||||
|
||||
function util.infobox_message(msg)
|
||||
|
@ -18,6 +18,6 @@
|
||||
</footer>
|
||||
<script src="/static/js/copy-code.js"></script>
|
||||
<script src="/static/js/date-fmt.js"></script>
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -6,6 +6,12 @@
|
||||
%>
|
||||
<form class="post-edit-form" method="post" action="<%= url or "" %>">
|
||||
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
|
||||
<% if not cancel_url then %>
|
||||
<span>
|
||||
<input type="checkbox" id="subscribe" name="subscribe" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||
<label for="subscribe">Subscribe to thread</label>
|
||||
</span>
|
||||
<% end %>
|
||||
<span>
|
||||
<input type=submit value="<%= save_button_text %>">
|
||||
<% if cancel_url then %>
|
||||
|
@ -9,6 +9,8 @@
|
||||
Welcome, <a href="<%= url_for("user", {username = me.username}) %>"><%= me.username %></a>
|
||||
•
|
||||
<a href="<%= url_for("user_settings", {username = me.username}) %>">Settings</a>
|
||||
•
|
||||
<a href="<%= url_for("user_inbox", {username = me.username}) %>">Inbox</a>
|
||||
<% if me:is_mod() then %>
|
||||
•
|
||||
<a href="<%= url_for("user_list") %>">User list</a>
|
||||
|
@ -18,7 +18,8 @@
|
||||
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
|
||||
<div class="post-info">
|
||||
<%
|
||||
local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||
--local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||
local post_url = get_post_url(post.id)
|
||||
%>
|
||||
<a href="<%= post_url %>" title="Permalink"><i>
|
||||
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||
@ -55,7 +56,7 @@
|
||||
<button value="<%= reply_text %>" class="reply-button">Quote</button>
|
||||
<% end %>
|
||||
<%
|
||||
local show_delete = (post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()
|
||||
local show_delete = ((post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()) and not no_reply
|
||||
if show_delete then
|
||||
%>
|
||||
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
|
||||
|
@ -14,8 +14,15 @@
|
||||
<% if is_stickied then %> • <i>stickied, so it's probably important</i>
|
||||
<% end %>
|
||||
</span>
|
||||
<% if can_lock then %>
|
||||
<div>
|
||||
<% if me:is_logged_in() then %>
|
||||
<form class="modform" action="<%= url_for("thread_subscribe", {slug = thread.slug}) %>" method="post">
|
||||
<input type="hidden" name="last_visible_post" value=<%= posts[#posts].id %>>
|
||||
<input type="hidden" name="subscribe" value=<%= is_subscribed and "unsubscribe" or "subscribe" %>>
|
||||
<input type="submit" value="<%= is_subscribed and "Unsubscribe" or "Subscribe" %>">
|
||||
</form>
|
||||
<% end %>
|
||||
<% if can_lock then %>
|
||||
<form class="modform" action="<%= url_for("thread_lock", {slug = thread.slug}) %>" method="post">
|
||||
<input type=hidden value="<%= not is_locked %>" name="target_op">
|
||||
<input class="warn" type="submit" value="<%= is_locked and "Unlock thread" or "Lock thread" %>">
|
||||
@ -35,8 +42,8 @@
|
||||
<input class="warn" type="submit" value="Move thread">
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
<% for i, post in ipairs(posts) do %>
|
||||
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
|
||||
|
35
views/user/inbox.etlua
Normal file
35
views/user/inbox.etlua
Normal file
@ -0,0 +1,35 @@
|
||||
<div class="darkbg">
|
||||
<h1 class="thread-title">Inbox</h1>
|
||||
</div>
|
||||
<div class="inbox-container">
|
||||
<% if #new_posts == 0 then %>
|
||||
You have no unread posts.
|
||||
<% else %>
|
||||
You have <%= total_unreads_count %> unread post<%= total_unreads_count > 1 and "s" or "" %>:
|
||||
<% for _, thread in ipairs(new_posts) do %>
|
||||
<div class="accordion">
|
||||
<div class="accordion-header">
|
||||
<button type="button" class="accordion-toggle">▼</button>
|
||||
<% local latest_post_id = thread.posts[#thread.posts].id %>
|
||||
<%
|
||||
local unread_posts_text = " (" .. thread.unread_count .. " unread post" .. (thread.unread_count > 1 and "s" or "")-- .. ")"
|
||||
%>
|
||||
<a class="accordion-title" href="<%= url_for("thread", {slug = thread.thread_slug}, {after = latest_post_id}) .. "#post-" .. latest_post_id %>" title="Jump to latest post"><%= thread.thread_title .. unread_posts_text %>, latest at <span class="timestamp" data-utc=<%= thread.newest_post_time %>><%= os.date("%c", thread.newest_post_time) %></span>)</a>
|
||||
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||
<input type="hidden" name="subscribe" value="read">
|
||||
<input type="submit" value="Mark Thread as Read">
|
||||
</form>
|
||||
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||
<input class="warn" type="submit" value="Unsubscribe">
|
||||
</form>
|
||||
</div>
|
||||
<div class="accordion-content">
|
||||
<% for _, post in ipairs(thread.posts) do %>
|
||||
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true, thread = get_thread_by_id(thread.thread_id)}) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
@ -24,6 +24,8 @@
|
||||
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="70" placeholder="Will be shown under your username. Max 70 characters">
|
||||
<label for="babycode-content">Signature</label><br>
|
||||
<% render("views.common.babycode-editor-component", {ta_name = "signature", prefill = me.signature_original_markup, ta_placeholder = "Will be shown under each of your posts", optional = true}) %>
|
||||
<input autocomplete="off" type="checkbox" id="subscribe_by_default" name="subscribe_by_default" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||
<label for="subscribe_by_default">Subscribe to thread by default when responding</label><br>
|
||||
<input type="submit" value="Save settings">
|
||||
</form>
|
||||
<form method="post" action="<%= url_for("user_change_password", {username = me.username}) %>">
|
||||
|
Loading…
Reference in New Issue
Block a user