source: Klonkt/src/routes/prutter.js@ d252ce8

main
Last change on this file since d252ce8 was 79bba6e, checked in by roboburr <roboburr@…>, 3 months ago

Audio never jumps + hub topnav + Prutter chat mobile-first

  • Global link boost in shell.ejs: all internal navigation links go through htmx into #pcms-main (session/auth/downloads/assets excluded), so the audio player keeps playing everywhere — including clicks within Admin. Back/forward re-fetches (historyCacheSize=0) so the OOB header comes along.
  • Hub-home: the mini topnav bar now shows on the hub landing (no large profile header — the hero is the header); duplicate .hub-nav removed.
  • Prutter conversation: full-height mobile-first chat (bodyClass on-chat → no artist profile header). Page pinned fixed between topnav and the bottom bars; only the thread scrolls. Compact header + clean composer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 6.5 KB
Line 
1/**
2 * Prutter — Direct Messaging routes.
3 *
4 * GET /prutter -> inbox (list of your conversations on THIS site)
5 * GET /prutter/new?to=<username> -> start (or resume) a conversation, redirects to /prutter/:id
6 * GET /prutter/:id -> conversation view (messages + send form)
7 * POST /prutter/:id/send -> send a message (HTMX-friendly response)
8 *
9 * Scoping: conversations are per-site (PrutterService.getOrCreateConversation
10 * uses res.locals.site.id). Robin's quote: "Prutter = DM (per .com domain)".
11 *
12 * Per-site toggle: site.enable_prutter == 0 -> 404 the whole feature.
13 *
14 * No anonymous DMs — always requires login.
15 */
16
17import express from 'express';
18import db from '../config/database.js';
19import { renderPage } from '../middleware/render.js';
20import { requireAuth, isViewer } from '../middleware/auth.js';
21import { getTenancy } from '../services/SettingsService.js';
22import { premiumUnlocked } from '../services/PatreonService.js';
23import PermissionsService from '../services/PermissionsService.js';
24
25const router = express.Router();
26
27const MAX_MESSAGE_LEN = 2000;
28
29function siteAllowsDM(req, res) {
30 const site = res.locals.site;
31 if (!site) return false;
32 return site.enable_prutter !== 0;
33}
34
35// Middleware: Prutter = Hub-feature achter de premium-laag, plus login.
36// - alleen in Hub-modus (DM tussen artiesten/leden van het collectief)
37// - vergrendeld als de premium-laag aan staat maar Patreon niet gekoppeld is
38// (premium uit = niet gegate → huidige gedrag, demo's blijven werken)
39// - geen anonieme DMs
40function requirePrutter(req, res, next) {
41 if (!siteAllowsDM(req, res)) return res.status(404).send('Prutter not enabled on this site');
42 if (getTenancy() !== 'hub') return res.status(404).send('Prutter is alleen beschikbaar in Hub-modus');
43 if (!premiumUnlocked()) {
44 return res.status(403).send('Prutter is een premium-functie — koppel Patreon in Beheer → Instellingen.');
45 }
46 return requireAuth(req, res, next);
47}
48
49// ==================== INBOX ====================
50router.get('/', requirePrutter, (req, res) => {
51 const prutter = req.app.locals.prutter;
52 const conversations = prutter.getUserConversations(req.session.user.id);
53
54 // Filter to only conversations on THIS site (Prutter scope is per-site)
55 const siteId = res.locals.site.id;
56 const scoped = conversations.filter(c => c.site_id === siteId);
57
58 renderPage(req, res, 'pages/prutter-inbox', {
59 pageTitle: 'Prutter',
60 bodyClass: 'on-special',
61 conversations: scoped,
62 });
63});
64
65// ==================== START / RESUME CONVERSATION ====================
66router.get('/new', requirePrutter, (req, res) => {
67 // Een gesprek starten is een schrijf-actie (INSERT) — een kijker mag dat niet.
68 // De globale guard pakt dit niet omdat het een GET is, dus expliciet blokkeren.
69 if (isViewer(req.session.user)) {
70 res.status(403);
71 return renderPage(req, res, 'pages/viewer-blocked', { pageTitle: 'Kijker-modus', bodyClass: 'on-special' });
72 }
73 const targetUsername = (req.query.to || '').toString().trim();
74 if (!targetUsername) {
75 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
76 }
77 const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
78 if (!target) return res.status(404).send('User not found');
79 if (target.id === req.session.user.id) {
80 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
81 }
82
83 const prutter = req.app.locals.prutter;
84 const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
85 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
86});
87
88// ==================== CONVERSATION VIEW ====================
89router.get('/:id', requirePrutter, (req, res) => {
90 const prutter = req.app.locals.prutter;
91 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
92 if (!conv) return res.status(404).send('Conversation not found');
93
94 // Auth: must be a participant
95 const me = req.session.user.id;
96 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
97
98 // Scope: this conversation must belong to the resolved site
99 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
100
101 // Other party
102 const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
103 const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
104
105 // Messages (oldest first for natural reading order)
106 const messages = prutter.getMessages(conv.id, 200, 0).reverse();
107
108 // Mark inbound messages as read — sla over voor kijkers (markAsRead is een
109 // UPDATE; een GET valt buiten de globale guard, dus hier expliciet skippen).
110 if (!isViewer(req.session.user)) prutter.markAsRead(conv.id, me);
111
112 renderPage(req, res, 'pages/prutter-conversation', {
113 pageTitle: 'Prutter — ' + (other?.username || ''),
114 // on-chat → full-height chat-view (geen artiest-profielkop, alleen de thread
115 // scrollt). Zie chrome.ejs (_headerless) + de page-CSS hieronder.
116 bodyClass: 'on-special on-chat',
117 conversation: conv,
118 other,
119 messages,
120 });
121});
122
123// ==================== SEND MESSAGE ====================
124router.post('/:id/send', requirePrutter, (req, res) => {
125 const prutter = req.app.locals.prutter;
126 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
127 if (!conv) return res.status(404).send('Not found');
128
129 const me = req.session.user.id;
130 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
131 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
132
133 const content = (req.body.content || '').toString().trim();
134 if (!content) return res.status(400).send('Empty');
135 if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
136
137 const message = prutter.sendMessage(conv.id, me, content);
138
139 // HTMX request → return the single rendered message HTML, appended to the thread
140 if (req.headers['hx-request']) {
141 return res.send(
142 `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
143 `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
144 `</li>`
145 );
146 }
147
148 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
149});
150
151function escapeHtml(s) {
152 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
153 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
154}
155
156export default router;
Note: See TracBrowser for help on using the repository browser.