From 12ae6ea6b5c02e0a820fe97997e2dcf36233b4a3 Mon Sep 17 00:00:00 2001 From: PortableProgrammer Date: Wed, 29 Jan 2025 18:08:28 +0000 Subject: [PATCH] Feat: Track Sessions * Tracks session views to reduce duplicate posts on subsequent pages. * Add preference to enable Track Sessions. * Standarizes preferences in database. * Consolidates and simplifies preference get/set code. * Adds preference descriptions to the `dashboard` view, mainly for Track Sessions. * Supplies an `init.sql` script to quickly build a local database. * Minor fixes --- .devcontainer/init/init.sql | 132 ++++++++++++++++++++++++++++++ src/db.js | 52 +++++++++++- src/mixins/header.pug | 4 +- src/public/styles.css | 84 ++++++++++++++++++- src/routes/index.js | 155 ++++++++++++++++++++++++++++-------- src/views/dashboard.pug | 34 ++++++-- src/views/index.pug | 6 +- 7 files changed, 420 insertions(+), 47 deletions(-) create mode 100644 .devcontainer/init/init.sql diff --git a/.devcontainer/init/init.sql b/.devcontainer/init/init.sql new file mode 100644 index 0000000..89511b7 --- /dev/null +++ b/.devcontainer/init/init.sql @@ -0,0 +1,132 @@ +/* Create a debug admin user + * User: debug + * Pass: test +INSERT INTO users (username, password_hash, isAdmin) +VALUES ('debug', '$argon2id$v=19$m=65536,t=2,p=1$Wk4wtKV3dv4IraDG2bq5cif5bIfmt8oRElEqAxk9AXY$LlUNVKfc/ivAqgEFYLgDqIgV76z2Gg35j2yl6cPf3UA', 1) + */ + +/* Toggle preferences +[card | compact] +UPDATE users SET pref_view = 'card' WHERE id = 1 + +[hot | best | new | rising | top | top&t=day | top&t=week | top&t=month | top&t=year | top&t=all] +UPDATE users SET pref_sort = 'best' WHERE id = 1 + +[0 | 1] +UPDATE users SET pref_collapseAutoMod = 1 WHERE id = 1 + +UPDATE users SET pref_trackSessions = 1 WHERE id = 1 + */ + +/* Subscribe to some subreddits +INSERT INTO subscriptions (user_id, subreddit) VALUES +(1, '3dshacks'), +(1, 'ATBGE'), +(1, 'AdviceAnimals'), +(1, 'Audi'), +(1, 'CatastrophicFailure'), +(1, 'CityPorn'), +(1, 'Colorado'), +(1, 'DMAcademy'), +(1, 'EarthPorn'), +(1, 'F1Porn'), +(1, 'F1Technical'), +(1, 'FinalFantasy'), +(1, 'Greyhounds'), +(1, 'HistoryPorn'), +(1, 'HomeServer'), +(1, 'IAmA'), +(1, 'ImaginaryArchitecture'), +(1, 'ImaginaryCityscapes'), +(1, 'ImaginaryLandscapes'), +(1, 'ImaginaryTechnology'), +(1, 'JRPG'), +(1, 'Justrolledintotheshop'), +(1, 'MadeMeSmile'), +(1, 'MilitaryPorn'), +(1, 'MinimalWallpaper'), +(1, 'MobileWallpaper'), +(1, 'PleX'), +(1, 'PowerShell'), +(1, 'ProgrammerHumor'), +(1, 'RetroArch'), +(1, 'RetroPie'), +(1, 'Roms'), +(1, 'Shitty_Car_Mods'), +(1, 'StarWars'), +(1, 'ThatLookedExpensive'), +(1, 'TopGear'), +(1, 'WhatsWrongWithYourDog'), +(1, 'adhdmeme'), +(1, 'apple'), +(1, 'battlemaps'), +(1, 'bestof'), +(1, 'books'), +(1, 'comics'), +(1, 'criticalrole'), +(1, 'csharp'), +(1, 'cyberpunkgame'), +(1, 'dndmaps'), +(1, 'dndmemes'), +(1, 'docker'), +(1, 'dotnet'), +(1, 'emulation'), +(1, 'food'), +(1, 'formula1'), +(1, 'formuladank'), +(1, 'funny'), +(1, 'gaming'), +(1, 'gifs'), +(1, 'homelab'), +(1, 'horizon'), +(1, 'iOSBeta'), +(1, 'ios'), +(1, 'iphonewallpapers'), +(1, 'k3s'), +(1, 'kubernetes'), +(1, 'longboyes'), +(1, 'macgaming'), +(1, 'macos'), +(1, 'macosbeta'), +(1, 'news'), +(1, 'pics'), +(1, 'printSF'), +(1, 'raspberry_pi'), +(1, 'reddit'), +(1, 'rpg'), +(1, 'science'), +(1, 'scifi'), +(1, 'selfhosted'), +(1, 'softwaregore'), +(1, 'spaceporn'), +(1, 'sysadmin'), +(1, 'talesfromtechsupport'), +(1, 'technology'), +(1, 'techsupportgore'), +(1, 'techsupportmacgyver'), +(1, 'thegrandtour'), +(1, 'thewholecar'), +(1, 'tiltshift'), +(1, 'todayilearned'), +(1, 'urbanexploration'), +(1, 'ubiquiti'), +(1, 'wallpaper'), +(1, 'wallpapers'), +(1, 'windowsinsiders'), +(1, 'worldnews'), +(1, 'xkcd') + */ + +/* Create some multireddits +INSERT INTO multireddits (user_id, multireddit, subreddit) VALUES +(1, 'F1', 'formula1'), +(1, 'F1', 'formuladank'), +(1, 'F1', 'f1technical'), +(1, 'F1', 'f1porn'), +(1, 'Homelab', 'homelab'), +(1, 'Homelab', 'selfhosted'), +(1, 'Homelab', 'homeserver'), +(1, 'Homelab', 'k3s'), +(1, 'Homelab', 'raspberry_pi'), +(1, 'Homelab', 'docker') + */ \ No newline at end of file diff --git a/src/db.js b/src/db.js index daf8a68..7291b3d 100644 --- a/src/db.js +++ b/src/db.js @@ -55,6 +55,27 @@ db.run(` ) `); +// sessions table +db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + subreddit TEXT, + query TEXT, + FOREIGN KEY(user_id) REFERENCES users(id), + UNIQUE(user_id, query) + ) +`); + +// views table +db.run(` + CREATE TABLE IF NOT EXISTS views ( + session_id INTEGER, + post_id TEXT, + FOREIGN KEY(session_id) REFERENCES sessions(id) + ) +`); + // migrations table db.query(` CREATE TABLE IF NOT EXISTS migrations ( @@ -87,7 +108,7 @@ runMigration("add-sort-view-pref-columns", () => { // Add viewPref column db.query(` ALTER TABLE users - ADD COLUMN viewPref TEXT DEFAULT 'compact' + ADD COLUMN viewPref TEXT DEFAULT 'card' `).run(); }); @@ -99,4 +120,33 @@ runMigration("add-collapse-automod-pref-column", () => { `).run(); }); +// Add Track Sessions pref +runMigration("add-track-sessions-pref-column", () => { + db.query(` + ALTER TABLE users + ADD COLUMN pref_trackSessions BOOLEAN DEFAULT 0 + `).run(); +}); + +// Standardize pref columns +runMigration("standardize-pref-columns", () => { + // viewPref -> pref_view + db.query(` + ALTER TABLE users + RENAME COLUMN viewPref TO pref_view + `).run(); + + // sortPref -> pref_sort + db.query(` + ALTER TABLE users + RENAME COLUMN sortPref TO pref_sort + `).run(); + + // collapseAutoModPref -> pref_collapseAutomod + db.query(` + ALTER TABLE users + RENAME COLUMN collapseAutoModPref TO pref_collapseAutomod + `).run(); +}); + module.exports = { db }; diff --git a/src/mixins/header.pug b/src/mixins/header.pug index e38b112..42f4d77 100644 --- a/src/mixins/header.pug +++ b/src/mixins/header.pug @@ -1,5 +1,5 @@ mixin header(user) - - var viewQuery = 'view=' + (prefs ? prefs.view : (query && query.view ? query.view : 'compact')) + - var viewQuery = 'view=' + (prefs ? prefs.view : (query && query.view ? query.view : 'card')) - var sortQuery = 'sort=' + (prefs ? prefs.sort : (query ? (query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot') : 'hot')) div.header div.header-item @@ -12,7 +12,7 @@ mixin header(user) a(href=`/subs?${viewQuery}`) subs if user div.header-item - a(href=`/dashboard${viewQuery}`) #{user.username} + a(href=`/dashboard?${viewQuery}`) #{user.username} |  a(href='/logout') (logout) else diff --git a/src/public/styles.css b/src/public/styles.css index b7c5856..c74f492 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -1,5 +1,15 @@ :root { - /* Light mode colors */ + /* Messages */ + --msg-success: #1a7f37; + --msg-success-muted: #4ac26b66; + --msg-info: #0969da; + --msg-info-muted: #54aeff66; + --msg-warn: #9a6700; + --msg-warn-muted: #d4a72c66; + --msg-danger: #cf222e; + --msg-danger-muted: #ff818266; + + /* Light Mode */ --bg-color: white; --bg-color-muted: #eee; --text-color: black; @@ -22,6 +32,17 @@ @media (prefers-color-scheme: dark) { :root { + /* Messages */ + --msg-success: #238636; + --msg-success-muted: #2ea04366; + --msg-info: #1f6feb; + --msg-info-muted: #388bfd66; + --msg-warn: #9e6a03; + --msg-warn-muted: #bb800966; + --msg-danger: #da3633; + --msg-danger-muted: #f8514966; + + /* Dark Mode */ --bg-color: black; --bg-color-muted: #333; --text-color: white; @@ -123,6 +144,15 @@ a:visited { margin-top: 20px; } +.preference:first-child { + margin-top: unset; +} + +.preference-group { + margin: 0.5rem 0rem 0.5rem 1rem; + padding: 0.5rem; +} + .pref-opts { display: grid; margin: 10px; @@ -750,7 +780,6 @@ input[type="checkbox"] { .preference label { margin: 0; margin-left: 5px; - color: var(--text-color); } form label { @@ -884,3 +913,54 @@ select { .inline { max-width: 100%; } + +.message { + padding: 8px 16px; + margin-bottom: 16px; + border-left: 0.25em solid var(--bg-color-muted); +} + +.message > p:first-of-type { + margin-top: unset; +} + +.message:last-of-type { + margin-bottom: 8px; +} + +.message.success { + border-left-color: var(--msg-success); +} + +.message.info { + border-left-color: var(--msg-info); +} + +.message.warning { + border-left-color: var(--msg-warn); +} + +.message.danger { + border-left-color: var(--msg-danger); +} + +.message-title { + font-weight: bold; + color: var(--text-color-muted); +} + +.message.success > .message-title { + color: var(--msg-success); +} + +.message.info > .message-title { + color: var(--msg-info); +} + +.message.warning > .message-title { + color: var(--msg-warn); +} + +.message.danger > .message-title { + color: var(--msg-danger); +} \ No newline at end of file diff --git a/src/routes/index.js b/src/routes/index.js index c0e2683..cad22f3 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -59,6 +59,110 @@ router.get("/r/:subreddit", authenticateToken, async (req, res) => { const [posts, about] = await Promise.all([postsReq, aboutReq]); + if (posts && prefs.trackSessions) { + // Reuse or create a new session + let session = + db.query(` + SELECT id, query + FROM sessions + WHERE user_id = $user_id AND subreddit = $subreddit + `).get({ user_id: req.user.id, subreddit: subreddit}); + if (!session || !req.query.after) { + // Limit sessions to one-per-user + + // Remove prior session(s) + session = + db.query(` + DELETE FROM sessions + WHERE user_id = $user_id + RETURNING id + `).all({ user_id: req.user.id }); + + // Remove prior view(s) + session.forEach((session) => { + db.query(` + DELETE FROM views + WHERE session_id = $session_id + `).run({ session_id: session.id }); + }); + + // Create a new session for this subreddit and query + session = + db.query(` + INSERT INTO sessions (user_id, subreddit, query) + VALUES ($user_id, $subreddit, $query) + RETURNING id, query + `).get({ user_id: req.user.id, subreddit: subreddit, query: posts.after }); + } + + // If we're on a different page, + if (session.query != req.query.after) { + // Get the views for this session + let views = + db.query(` + SELECT post_id FROM views WHERE session_id = $session_id + `).all({ session_id: session.id }); + + // Remove any previously-seen posts from this session + let removedPosts = 0; + let postsToRemove = new Set(views.map(view => view.post_id)).intersection(new Set(posts.posts.map(post => post.data.id))); + + for (const id of postsToRemove) { + let index = posts.posts.findIndex((post) => post.data.id == id); + if (index >= 0) { + posts.posts.splice(index, 1); + removedPosts++; + } + } + + // Get `removedPosts` more posts + while (removedPosts > 0) { + const postsReq = G.getSubmissions(query.sort, `${subreddit}`, { ...query, after: posts.after, limit: removedPosts }); + const [extraPosts] = await Promise.all([postsReq]); + + // If we fail to retrieve any more posts, give up entirely and render the view + if (!extraPosts) { + break; + } + + // Remove any previously-seen posts from this session (including those we just pulled for this view) + let postsToRemove = new Set(views.map(view => view.post_id)).union(new Set(posts.posts.map(post => post.data.id))).intersection(new Set(extraPosts.posts.map(post => post.data.id))); + + for (const id of postsToRemove) { + let index = extraPosts.posts.findIndex((post) => post.data.id == id); + if (index >= 0) { + extraPosts.posts.splice(index, 1); + } + } + + // Pop these onto the end of the new posts + for (const post of extraPosts.posts) { + posts.posts.push(post); + } + + // Update the `after` value + posts.after = extraPosts.after; + // Decrement counter + removedPosts -= extraPosts.posts.length; + } + + // Update the session + db.query(` + UPDATE sessions + SET query = $query + WHERE id = $session_id + `).run({ session_id: session.id, query: req.query.after }); + + // Insert the resulting set of views + posts.posts.forEach((post) => { + db.query(` + INSERT INTO views (session_id, post_id) + VALUES ($session_id, $post_id) + `).run({ session_id: session.id, post_id: post.data.id }); + }); + } + } + if (query.view == 'card' && posts && posts.posts) { posts.posts.forEach(unescape_selftext); posts.posts.forEach(unescape_media_embed); @@ -539,36 +643,20 @@ router.post("/unsubscribe", authenticateToken, async (req, res) => { router.post("/set-pref", authenticateToken, async (req, res) => { const { preference, value} = req.body; const user = req.user; + const validPrefs = ['sort', 'view', 'collapseAutoMod', 'trackSessions'] - switch(preference) { - case 'sort': - db.query(` - UPDATE users - SET sortPref = $value - WHERE id = $user_id - `) - .run({ user_id: user.id, value: value }); - break; - - case 'view': - db.query(` - UPDATE users - SET viewPref = $value - WHERE id = $user_id - `) - .run({ user_id: user.id, value: value }); - break; - - case 'collapseAutoMod': - db.query(` - UPDATE users - SET collapseAutoModPref = $value - WHERE id = $user_id - `) - .run({ user_id: user.id, value: value }); - break; - } - res.status(200).send("Updated successfully"); + if (validPrefs.includes(preference)) { + var query = ` + UPDATE users + SET pref_${preference} = $value + WHERE id = $user_id + `; + db.query(query).run({ user_id: user.id, value: value }); + res.status(200).send("Updated successfully"); + } + else { + res.status(400).send("Invalid preference"); + } }); // POST /multi-add @@ -605,15 +693,16 @@ module.exports = router; function get_user_prefs(user_id) { const prefs = db.query(` - SELECT sortPref, viewPref, collapseAutoModPref + SELECT pref_sort, pref_view, pref_collapseAutoMod, pref_trackSessions FROM users WHERE id = $user_id `) .all({ user_id: user_id }) .map((pref) => ({ - sort: pref.sortPref, - view: pref.viewPref, - collapseAutoMod: pref.collapseAutoModPref, + sort: pref.pref_sort, + view: pref.pref_view, + collapseAutoMod: pref.pref_collapseAutoMod, + trackSessions: pref.pref_trackSessions, })); return prefs[0]; diff --git a/src/views/dashboard.pug b/src/views/dashboard.pug index 7279723..bb4ec1c 100644 --- a/src/views/dashboard.pug +++ b/src/views/dashboard.pug @@ -21,7 +21,7 @@ html h2 preferences details.preference - summary sort: #{prefs.sort} + summary Sort: #{prefs.sort} div.pref-opts div a(href=`/dashboard` onclick=`setPref('sort', 'hot')` id=`pref_sort_hot`) hot @@ -45,15 +45,37 @@ html a(href=`/dashboard` onclick=`setPref('sort', 'top&t=all')` id=`pref_sort_top_all`) top all details.preference - summary view: #{prefs.view} - div.pref-opts - div - a(href=`/dashboard` onclick=`setPref('view', 'compact')` id=`pref_view_compact`) compact + summary View: #{prefs.view} + div.pref-opts.view-opts div a(href=`/dashboard` onclick=`setPref('view', 'card')` id=`pref_view_card`) card + div + a(href=`/dashboard` onclick=`setPref('view', 'compact')` id=`pref_view_compact`) compact div.preference input(type="checkbox" id="pref_collapseAutoMod" checked=prefs.collapseAutoMod!==0 onclick=`togglePref('collapseAutoMod')`) - label(for="pref_collapseAutoMod") Collapse AutoMod Comments + label(for="pref_collapseAutoMod") Collapse Automod Comments + div.message + p Automaticaly collapses comments made by   + code u/AutoModerator + | . + + details.preference + summary(style="color: var(--msg-warn);") Experimental - Proceed with caution + div.preference-group + div.preference + input(type="checkbox" id="pref_trackSessions" checked=prefs.trackSessions!==0 onclick=`togglePref('trackSessions')`) + label(for="pref_trackSessions") Track Session Views + div.message.warning + p.message-title Warning + p May increase page load times significantly after extended usage + div.message + p Limits duplicate posts by storing post ids already seen during the session, removing them from the feed on subsequent pages, and loading enough new posts to make up the difference. A session is started when you first click   + code next ⟶ + |   while browsing, and ends when you navigate to + code /home + | , + code /all + | , or another subreddit, multireddit, etc. if isAdmin h2 invites diff --git a/src/views/index.pug b/src/views/index.pug index 87bcb5c..366784c 100644 --- a/src/views/index.pug +++ b/src/views/index.pug @@ -70,11 +70,11 @@ html a(href=`/r/${subreddit}?sort=top&t=year&view=${viewQuery}` onclick=`setPref('sort', 'top&t=year')` id=`pref_sort_top_year`) top year div a(href=`/r/${subreddit}?sort=top&t=all&view=${viewQuery}` onclick=`setPref('sort', 'top&t=all')` id=`pref_sort_top_all`) top all - div.pref-opts - div - a(href=`/r/${subreddit}?sort=${sortQuery}&view=compact` onclick=`setPref('view', 'compact')` id=`pref_view_compact`) compact + div.pref-opts.view-opts div a(href=`/r/${subreddit}?sort=${sortQuery}&view=card` onclick=`setPref('view', 'card')` id=`pref_view_card`) card + div + a(href=`/r/${subreddit}?sort=${sortQuery}&view=compact` onclick=`setPref('view', 'compact')` id=`pref_view_compact`) compact if posts each child in posts.posts