Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions lib/md.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,22 @@ export function mdHas (md, test) {
return found
}

export function extractUrls (md) {
// `limit` limits the number of urls collected while visiting the tree
// `type` narrows the search to a single mdast node type (e.g. 'image')
export function extractUrls (md, { limit, type } = {}) {
if (!md) return []
const tree = fromMarkdown(md, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()]
})

const urls = new Set()
visit(tree, ({ type }) => {
return type === 'link' || type === 'image'
visit(tree, (node) => {
if (type) return node.type === type
return node.type === 'link' || node.type === 'image'
}, ({ url }) => {
urls.add(url)
if (limit && urls.size >= limit) return false
})

return Array.from(urls)
Expand Down
131 changes: 89 additions & 42 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,82 @@ import { nextBillingWithGrace } from '@/lib/territory'
import models from '@/api/models'
import { isMuted } from '@/lib/user'
import { Prisma } from '@prisma/client'
import { extractUrls } from '@/lib/md'
import { createImgproxyPath, imgProxyEnabled } from '@/lib/imgproxy'

// Check if an item meets a user's sat filter threshold for push notifications
async function meetsUserSatFilter (userId, item) {
const user = await models.user.findUnique({
where: { id: userId },
select: { postsSatsFilter: true, commentsSatsFilter: true }
})

function passesSatFilter (user, item) {
const isPost = !!item.title

const filter = user
? (isPost ? user.postsSatsFilter : user.commentsSatsFilter)
: (isPost ? DEFAULT_POSTS_SATS_FILTER : DEFAULT_COMMENTS_SATS_FILTER)

// null means "show all" — item always passes
// null means "show all" — item always passes.
if (filter == null) return true
return item.netInvestment >= filter
}

// Batch version of meetsUserSatFilter: fetches all users' sat filter
// preferences in a single query instead of N individual findUnique calls.
// Returns a Set of user IDs that meet the sat filter threshold for the item.
async function getUsersPassingSatFilter (userIds, item) {
if (!userIds.length) return new Set()
// single-user notification preferences for an item
// returns whether the item passes the user's sat filter and whether they
// want media embedded.
async function getUserNotificationPrefs (userId, item) {
const user = await models.user.findUnique({
where: { id: userId },
select: { postsSatsFilter: true, commentsSatsFilter: true, showImagesAndVideos: true }
})

return {
passesSatFilter: passesSatFilter(user, item),
wantsMedia: user?.showImagesAndVideos ?? true
}
}

const isPost = !!item.title
const defaultFilter = isPost ? DEFAULT_POSTS_SATS_FILTER : DEFAULT_COMMENTS_SATS_FILTER
// Batch version of getUserNotificationPrefs: fetches all users' preferences in a
// single query instead of N individual findUnique calls. Returns two Sets of
// user IDs — those passing the sat filter, and those wanting media embedded.
async function getBatchNotificationPrefs (userIds, item) {
const prefs = { passingSatFilter: new Set(), wantingMedia: new Set() }
if (!userIds.length) return prefs

const users = await models.user.findMany({
where: { id: { in: userIds } },
select: { id: true, postsSatsFilter: true, commentsSatsFilter: true }
select: { id: true, postsSatsFilter: true, commentsSatsFilter: true, showImagesAndVideos: true }
})

const userMap = new Map(users.map(u => [u.id, u]))

return new Set(
userIds.filter(id => {
const user = userMap.get(id)
const filter = user
? (isPost ? user.postsSatsFilter : user.commentsSatsFilter)
: defaultFilter
// null means "show all" — item always passes
if (filter == null) return true
return item.netInvestment >= filter
})
)
for (const id of userIds) {
const user = userMap.get(id)
if (passesSatFilter(user, item)) prefs.passingSatFilter.add(id)
// showImagesAndVideos defaults to true: unknown user / missing row → show media
if (user?.showImagesAndVideos ?? true) prefs.wantingMedia.add(id)
}

return prefs
}

// notifications should be less than 1MB
const NOTIFICATION_IMAGE_OPTIONS = '/rs:fit:960x540'

// builds a notification-sized imgproxy url from an original media url
function toNotificationImage (mediaUrl) {
if (!imgProxyEnabled || !process.env.NEXT_PUBLIC_IMGPROXY_URL) return mediaUrl

const path = createImgproxyPath({ url: mediaUrl, options: NOTIFICATION_IMAGE_OPTIONS })
return new URL(path, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
}

// takes the first image url from imgproxyUrls or markdown text
// returns a notification-sized imgproxy url
function getFirstImageUrl (item) {
// if we already confirmed an image url in `imgproxyUrls`, use it
const [mediaUrl] = Object.keys(item.imgproxyUrls ?? {})
if (mediaUrl) return toNotificationImage(mediaUrl)

// otherwise, extract the first image url from the markdown text
const [imageUrl] = extractUrls(item.text, { type: 'image', limit: 1 })
return imageUrl ? toNotificationImage(imageUrl) : null
}

const webPushEnabled = process.env.NODE_ENV === 'production' ||
Expand Down Expand Up @@ -215,18 +246,21 @@ export async function notifyUserSubscribers ({ models, item }) {
)`
: Prisma.empty}`

// Batch-fetch sat filter preferences in a single query instead of per-user
const passingUserIds = await getUsersPassingSatFilter(
// Batch-fetch notification preferences in a single query instead of per-user
const { passingSatFilter, wantingMedia } = await getBatchNotificationPrefs(
userSubsExcludingMutes.map(sub => sub.followerId),
item
)

const image = getFirstImageUrl(item)

await Promise.allSettled(
userSubsExcludingMutes
.filter(({ followerId }) => passingUserIds.has(followerId))
.filter(({ followerId }) => passingSatFilter.has(followerId))
.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
body: isPost ? item.title : item.text,
image: wantingMedia.has(followerId) ? image : undefined,
itemId: item.id
}))
)
Expand Down Expand Up @@ -259,19 +293,22 @@ export async function notifyTerritorySubscribers ({ models, item }) {
const subsExcludingAuthor = territorySubsExcludingMuted
.filter(({ userId }) => userId !== author.id)

// Batch-fetch sat filter preferences in a single query instead of per-user
const passingUserIds = await getUsersPassingSatFilter(
// Batch-fetch notification preferences in a single query instead of per-user
const { passingSatFilter, wantingMedia } = await getBatchNotificationPrefs(
subsExcludingAuthor.map(sub => sub.userId),
item
)

const image = getFirstImageUrl(item)

await Promise.allSettled(
subsExcludingAuthor
.filter(({ userId }) => passingUserIds.has(userId))
.filter(({ userId }) => passingSatFilter.has(userId))
.map(({ userId, subName }) =>
sendUserNotification(userId, {
title: `@${author.name} created a post in ~${subName}`,
body: item.title,
image: wantingMedia.has(userId) ? image : undefined,
itemId: item.id
}))
)
Expand Down Expand Up @@ -302,19 +339,22 @@ export async function notifyThreadSubscribers ({ models, item }) {
WHERE i.id = ${item.parentId} AND p."userId" = "ThreadSubscription"."userId" AND users."noteAllDescendants"
)`

// Batch-fetch sat filter preferences in a single query instead of per-user
const passingUserIds = await getUsersPassingSatFilter(
// Batch-fetch notification preferences in a single query instead of per-user
const { passingSatFilter, wantingMedia } = await getBatchNotificationPrefs(
subscribers.map(sub => sub.userId),
item
)

const image = getFirstImageUrl(item)

await Promise.allSettled(
subscribers
.filter(({ userId }) => passingUserIds.has(userId))
.filter(({ userId }) => passingSatFilter.has(userId))
.map(({ userId }) =>
sendUserNotification(userId, {
title: `@${author.name} replied to a post`,
body: item.text,
image: wantingMedia.has(userId) ? image : undefined,
itemId: item.id
})
)
Expand All @@ -341,19 +381,22 @@ export async function notifyItemParents ({ models, item }) {
SELECT 1 FROM "ThreadSubscription" ts WHERE p.id = ts."itemId" AND p."userId" = ts."userId"
)`

// Batch-fetch sat filter preferences in a single query instead of per-user
const passingUserIds = await getUsersPassingSatFilter(
// Batch-fetch notification preferences in a single query instead of per-user
const { passingSatFilter, wantingMedia } = await getBatchNotificationPrefs(
parents.map(parent => parent.userId),
item
)

const image = getFirstImageUrl(item)

await Promise.allSettled(
parents
.filter(({ userId }) => passingUserIds.has(userId))
.filter(({ userId }) => passingSatFilter.has(userId))
.map(({ userId, isDirect }) => {
return sendUserNotification(userId, {
title: `@${user.name} replied to you`,
body: item.text,
image: wantingMedia.has(userId) ? image : undefined,
itemId: item.id,
setting: isDirect ? undefined : 'noteAllDescendants'
})
Expand Down Expand Up @@ -446,12 +489,14 @@ export async function notifyMention ({ models, userId, item }) {
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
if (muted) return

// Check if item meets user's sat filter
if (!await meetsUserSatFilter(userId, item)) return
// Check if item meets user's sat filter, and whether they want media
const { passesSatFilter, wantsMedia } = await getUserNotificationPrefs(userId, item)
if (!passesSatFilter) return

await sendUserNotification(userId, {
title: `@${item.user.name} mentioned you`,
body: item.text,
image: wantsMedia ? getFirstImageUrl(item) : undefined,
itemId: item.id,
setting: 'noteMentions'
})
Expand All @@ -465,8 +510,9 @@ export async function notifyItemMention ({ models, referrerItem, refereeItem })
const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId })
if (muted) return

// Check if referrer item meets user's sat filter
if (!await meetsUserSatFilter(refereeItem.userId, referrerItem)) return
// Check if referrer item meets user's sat filter, and whether they want media
const { passesSatFilter, wantsMedia } = await getUserNotificationPrefs(refereeItem.userId, referrerItem)
if (!passesSatFilter) return

const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } })

Expand All @@ -476,6 +522,7 @@ export async function notifyItemMention ({ models, referrerItem, refereeItem })
await sendUserNotification(refereeItem.userId, {
title: `@${referrer.name} mentioned one of your items`,
body,
image: wantsMedia ? getFirstImageUrl(referrerItem) : undefined,
itemId: referrerItem.id,
setting: 'noteItemMentions'
})
Expand Down
Loading