global push notifications toggle, inline status change on post detail
This commit is contained in:
@@ -9,7 +9,7 @@ import { firePluginEvent } from "../services/webhooks.js";
|
|||||||
import { decrypt } from "../services/encryption.js";
|
import { decrypt } from "../services/encryption.js";
|
||||||
import { masterKey } from "../config.js";
|
import { masterKey } from "../config.js";
|
||||||
import { shouldCount } from "../lib/view-tracker.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;
|
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}`,
|
url: `/b/${board.slug}/post/${post.id}`,
|
||||||
tag: `board-${board.id}-new`,
|
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({
|
reply.status(201).send({
|
||||||
id: post.id, type: post.type, title: post.title, description: post.description,
|
id: post.id, type: post.type, title: post.title, description: post.description,
|
||||||
|
|||||||
@@ -139,4 +139,60 @@ export default async function pushRoutes(app: FastifyInstance) {
|
|||||||
reply.send(subs);
|
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 });
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export async function notifyBoardSubscribers(boardId: string, event: PushPayload
|
|||||||
await processResults(subs, event);
|
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) {
|
export async function notifyUserReply(userId: string, event: PushPayload) {
|
||||||
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
|
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
|
||||||
await processResults(subs, event);
|
await processResults(subs, event);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import StatusBadge from '../components/StatusBadge'
|
|||||||
import Timeline from '../components/Timeline'
|
import Timeline from '../components/Timeline'
|
||||||
import type { TimelineEntry } from '../components/Timeline'
|
import type { TimelineEntry } from '../components/Timeline'
|
||||||
import PluginSlot from '../components/PluginSlot'
|
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 EditHistoryModal from '../components/EditHistoryModal'
|
||||||
import Avatar from '../components/Avatar'
|
import Avatar from '../components/Avatar'
|
||||||
import Markdown from '../components/Markdown'
|
import Markdown from '../components/Markdown'
|
||||||
@@ -202,6 +203,30 @@ export default function PostDetail() {
|
|||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
const [showThreadLockDialog, setShowThreadLockDialog] = useState(false)
|
const [showThreadLockDialog, setShowThreadLockDialog] = useState(false)
|
||||||
const [threadLockVoting, setThreadLockVoting] = 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 () => {
|
const fetchPost = async () => {
|
||||||
if (!boardSlug || !postId) return
|
if (!boardSlug || !postId) return
|
||||||
@@ -523,7 +548,18 @@ export default function PostDetail() {
|
|||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
{post.isPinned && <IconPin size={14} stroke={2} style={{ color: 'var(--accent)' }} />}
|
{post.isPinned && <IconPin size={14} stroke={2} style={{ color: 'var(--accent)' }} />}
|
||||||
<StatusBadge status={post.status} />
|
{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 && (
|
{post.category && (
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5"
|
className="px-2 py-0.5"
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
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'
|
import type { Icon } from '@tabler/icons-react'
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@@ -15,16 +16,58 @@ interface Stats {
|
|||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
useDocumentTitle('Admin')
|
useDocumentTitle('Admin')
|
||||||
|
const toast = useToast()
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [globalNotif, setGlobalNotif] = useState(false)
|
||||||
|
const [notifLoading, setNotifLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<Stats>('/admin/stats')
|
api.get<Stats>('/admin/stats')
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.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 ? [
|
const statCards = stats ? [
|
||||||
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
|
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
|
||||||
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
|
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
|
||||||
@@ -84,6 +127,32 @@ export default function AdminDashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Posts by status bar chart */}
|
||||||
{stats && stats.totalPosts > 0 && (
|
{stats && stats.totalPosts > 0 && (
|
||||||
<div className="card p-5 mb-8">
|
<div className="card p-5 mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user