global push notifications toggle, inline status change on post detail

This commit is contained in:
2026-03-22 07:29:08 +02:00
parent 9f4b92cc36
commit 6b110a6d90
5 changed files with 176 additions and 4 deletions

View File

@@ -9,7 +9,7 @@ import { firePluginEvent } from "../services/webhooks.js";
import { decrypt } from "../services/encryption.js";
import { masterKey } from "../config.js";
import { shouldCount } from "../lib/view-tracker.js";
import { notifyBoardSubscribers } from "../services/push.js";
import { notifyBoardSubscribers, notifyGlobalSubscribers } from "../services/push.js";
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
@@ -556,6 +556,12 @@ export default async function postRoutes(app: FastifyInstance) {
url: `/b/${board.slug}/post/${post.id}`,
tag: `board-${board.id}-new`,
});
notifyGlobalSubscribers({
title: `New post in ${board.name}`,
body: cleanTitle.slice(0, 100),
url: `/b/${board.slug}/post/${post.id}`,
tag: `global-new-post`,
});
reply.status(201).send({
id: post.id, type: post.type, title: post.title, description: post.description,

View File

@@ -139,4 +139,60 @@ export default async function pushRoutes(app: FastifyInstance) {
reply.send(subs);
}
);
// global subscription status
app.get(
"/push/global-subscription",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
const sub = await prisma.pushSubscription.findFirst({
where: { userId: req.user!.id, boardId: null, postId: null },
select: { id: true },
});
reply.send({ subscribed: !!sub });
}
);
// global subscribe
app.post(
"/push/global-subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = subscribeBody.parse(req.body);
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
if (existing) {
await prisma.pushSubscription.update({
where: { endpointIdx },
data: { userId: req.user!.id, boardId: null, postId: null },
});
} else {
await prisma.pushSubscription.create({
data: {
endpoint: encrypt(body.endpoint, masterKey),
endpointIdx,
keysP256dh: encrypt(body.keys.p256dh, masterKey),
keysAuth: encrypt(body.keys.auth, masterKey),
userId: req.user!.id,
boardId: null,
postId: null,
},
});
}
reply.send({ subscribed: true });
}
);
// global unsubscribe
app.delete(
"/push/global-subscribe",
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
await prisma.pushSubscription.deleteMany({
where: { userId: req.user!.id, boardId: null, postId: null },
});
reply.send({ subscribed: false });
}
);
}

View File

@@ -82,6 +82,11 @@ export async function notifyBoardSubscribers(boardId: string, event: PushPayload
await processResults(subs, event);
}
export async function notifyGlobalSubscribers(event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { boardId: null, postId: null } });
await processResults(subs, event);
}
export async function notifyUserReply(userId: string, event: PushPayload) {
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
await processResults(subs, event);

View File

@@ -8,7 +8,8 @@ import StatusBadge from '../components/StatusBadge'
import Timeline from '../components/Timeline'
import type { TimelineEntry } from '../components/Timeline'
import PluginSlot from '../components/PluginSlot'
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage } from '@tabler/icons-react'
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
import Dropdown from '../components/Dropdown'
import EditHistoryModal from '../components/EditHistoryModal'
import Avatar from '../components/Avatar'
import Markdown from '../components/Markdown'
@@ -202,6 +203,30 @@ export default function PostDetail() {
const [historyLoading, setHistoryLoading] = useState(false)
const [showThreadLockDialog, setShowThreadLockDialog] = useState(false)
const [threadLockVoting, setThreadLockVoting] = useState(false)
const [boardStatuses, setBoardStatuses] = useState<{ status: string; label: string }[]>([])
const [statusChanging, setStatusChanging] = useState(false)
useEffect(() => {
if (!boardSlug) return
api.get<{ statuses: { status: string; label: string }[] }>(`/boards/${boardSlug}/statuses`)
.then((r) => setBoardStatuses(r.statuses))
.catch(() => {})
}, [boardSlug])
const changeStatus = async (newStatus: string) => {
if (!postId || !post || newStatus === post.status) return
setStatusChanging(true)
try {
await api.put(`/admin/posts/${postId}/status`, { status: newStatus })
setPost({ ...post, status: newStatus })
fetchPost()
toast.success('Status updated')
} catch {
toast.error('Failed to update status')
} finally {
setStatusChanging(false)
}
}
const fetchPost = async () => {
if (!boardSlug || !postId) return
@@ -523,7 +548,18 @@ export default function PostDetail() {
{typeLabel}
</span>
{post.isPinned && <IconPin size={14} stroke={2} style={{ color: 'var(--accent)' }} />}
{admin.isAdmin && boardStatuses.length > 0 ? (
<div style={{ minWidth: 140 }}>
<Dropdown
value={post.status}
options={boardStatuses.map((s) => ({ value: s.status, label: s.label }))}
onChange={changeStatus}
aria-label="Change status"
/>
</div>
) : (
<StatusBadge status={post.status} />
)}
{post.category && (
<span
className="px-2 py-0.5"

View File

@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash } from '@tabler/icons-react'
import { useToast } from '../../hooks/useToast'
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash, IconBell, IconBellOff } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface Stats {
@@ -15,16 +16,58 @@ interface Stats {
export default function AdminDashboard() {
useDocumentTitle('Admin')
const toast = useToast()
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [globalNotif, setGlobalNotif] = useState(false)
const [notifLoading, setNotifLoading] = useState(false)
useEffect(() => {
api.get<Stats>('/admin/stats')
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false))
api.get<{ subscribed: boolean }>('/push/global-subscription')
.then((r) => setGlobalNotif(r.subscribed))
.catch(() => {})
}, [])
const toggleGlobalNotif = async () => {
if (notifLoading) return
setNotifLoading(true)
try {
if (globalNotif) {
await api.delete('/push/global-subscribe')
setGlobalNotif(false)
toast.info('Global notifications disabled')
} else {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
toast.error('Push notifications not supported in this browser')
return
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
toast.warning('Notification permission denied')
return
}
const reg = await navigator.serviceWorker.ready
const vapid = await api.get<{ publicKey: string }>('/push/vapid')
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid.publicKey })
const json = sub.toJSON()
await api.post('/push/global-subscribe', {
endpoint: json.endpoint,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
})
setGlobalNotif(true)
toast.success('Global notifications enabled for all boards')
}
} catch {
toast.error('Failed to toggle notifications')
} finally {
setNotifLoading(false)
}
}
const statCards = stats ? [
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
@@ -84,6 +127,32 @@ export default function AdminDashboard() {
))}
</div>
{/* Global notifications */}
<div className="card card-static p-4 mb-8 flex items-center justify-between">
<div>
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
All-board notifications
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Get notified when anyone posts on any board
</div>
</div>
<button
onClick={toggleGlobalNotif}
disabled={notifLoading}
className="btn btn-secondary flex items-center gap-1.5"
style={{
fontSize: 'var(--text-xs)',
color: globalNotif ? 'var(--accent)' : 'var(--text-secondary)',
opacity: notifLoading ? 0.6 : 1,
}}
aria-label={globalNotif ? 'Disable all-board notifications' : 'Enable all-board notifications'}
>
{globalNotif ? <IconBell size={14} stroke={2} /> : <IconBellOff size={14} stroke={2} />}
{globalNotif ? 'Enabled' : 'Disabled'}
</button>
</div>
{/* Posts by status bar chart */}
{stats && stats.totalPosts > 0 && (
<div className="card p-5 mb-8">