diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts index c067185..5286eeb 100644 --- a/packages/api/src/routes/posts.ts +++ b/packages/api/src/routes/posts.ts @@ -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, diff --git a/packages/api/src/routes/push.ts b/packages/api/src/routes/push.ts index ccd58b3..b95e7e1 100644 --- a/packages/api/src/routes/push.ts +++ b/packages/api/src/routes/push.ts @@ -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 }); + } + ); } diff --git a/packages/api/src/services/push.ts b/packages/api/src/services/push.ts index 4a8e447..9f51785 100644 --- a/packages/api/src/services/push.ts +++ b/packages/api/src/services/push.ts @@ -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); diff --git a/packages/web/src/pages/PostDetail.tsx b/packages/web/src/pages/PostDetail.tsx index 56e6550..fed2e2c 100644 --- a/packages/web/src/pages/PostDetail.tsx +++ b/packages/web/src/pages/PostDetail.tsx @@ -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} {post.isPinned && } - + {admin.isAdmin && boardStatuses.length > 0 ? ( +
+ ({ value: s.status, label: s.label }))} + onChange={changeStatus} + aria-label="Change status" + /> +
+ ) : ( + + )} {post.category && ( (null) const [loading, setLoading] = useState(true) + const [globalNotif, setGlobalNotif] = useState(false) + const [notifLoading, setNotifLoading] = useState(false) useEffect(() => { api.get('/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() { ))} + {/* Global notifications */} +
+
+
+ All-board notifications +
+
+ Get notified when anyone posts on any board +
+
+ +
+ {/* Posts by status bar chart */} {stats && stats.totalPosts > 0 && (