From 2597eb56f3a167c49aed8b34035da5b6f50be25b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 3 Jun 2024 12:12:42 -0500 Subject: [PATCH] Item mention notifications (#1208) * Parse internal refs to links * Item mention notifications * Also parse item mentions as URLs * Fix subType determined by referrer item instead of referee item * Ignore subType Considering if the item that was referred to was a post or comment made the code more complex than initially necessary. For example, notifications for /notifications are deduplicated based on item id and the same item could refer to posts and comments, so to include "one of your posts" or "one of your comments" in the title would require splitting notifications based on the type of referred item. I didn't want to do this but also wanted to have consistent notification titles between push and /notifications, so I use "items" in both places now, even though I think using "items" isn't ideal from a user perspective. I think it might be confusing. * Fix rootText * Replace full links to # syntax in push notifications * Refactor mention code into separate functions --- api/resolvers/item.js | 115 +++++++++++++----- api/resolvers/notifications.js | 20 +++ api/resolvers/user.js | 20 +++ api/typeDefs/notifications.js | 8 +- api/typeDefs/user.js | 2 + components/notifications.js | 21 ++++ components/text.js | 3 +- fragments/notifications.js | 8 ++ fragments/users.js | 2 + lib/remark-ref2link.js | 26 ++++ lib/webPush.js | 22 ++++ pages/settings/index.js | 6 + .../migration.sql | 31 +++++ prisma/schema.prisma | 18 +++ sw/eventListener.js | 4 +- 15 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 lib/remark-ref2link.js create mode 100644 prisma/migrations/20240529105359_item_mentions/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ac67353a..5138efb9 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -15,7 +15,7 @@ import { msatsToSats } from '@/lib/format' import { parse } from 'tldts' import uu from 'url-unshort' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' -import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush' +import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item' import { datePivot, whenRange } from '@/lib/time' import { imageFeesInfo, uploadIdsFromText } from './image' @@ -1179,6 +1179,7 @@ export default { } const namePattern = /\B@[\w_]+/gi +const refPattern = new RegExp(`(?:#|${process.env.NEXT_PUBLIC_URL}/items/)(?\\d+)`, 'gi') export const createMentions = async (item, models) => { // if we miss a mention, in the rare circumstance there's some kind of @@ -1188,40 +1189,90 @@ export const createMentions = async (item, models) => { return } + // user mentions try { - const mentions = item.text.match(namePattern)?.map(m => m.slice(1)) - if (mentions?.length > 0) { - const users = await models.user.findMany({ - where: { - name: { in: mentions }, - // Don't create mentions when mentioning yourself - id: { not: item.userId } - } - }) - - users.forEach(async user => { - const data = { - itemId: item.id, - userId: user.id - } - - const mention = await models.mention.upsert({ - where: { - itemId_userId: data - }, - update: data, - create: data - }) - - // only send if mention is new to avoid duplicates - if (mention.createdAt.getTime() === mention.updatedAt.getTime()) { - notifyMention({ models, userId: user.id, item }) - } - }) - } + await createUserMentions(item, models) } catch (e) { - console.error('mention failure', e) + console.error('user mention failure', e) } + + // item mentions + try { + await createItemMentions(item, models) + } catch (e) { + console.error('item mention failure', e) + } +} + +const createUserMentions = async (item, models) => { + const mentions = item.text.match(namePattern)?.map(m => m.slice(1)) + if (!mentions || mentions.length === 0) return + + const users = await models.user.findMany({ + where: { + name: { in: mentions }, + // Don't create mentions when mentioning yourself + id: { not: item.userId } + } + }) + + users.forEach(async user => { + const data = { + itemId: item.id, + userId: user.id + } + + const mention = await models.mention.upsert({ + where: { + itemId_userId: data + }, + update: data, + create: data + }) + + // only send if mention is new to avoid duplicates + if (mention.createdAt.getTime() === mention.updatedAt.getTime()) { + notifyMention({ models, userId: user.id, item }) + } + }) +} + +const createItemMentions = async (item, models) => { + const refs = item.text.match(refPattern)?.map(m => { + if (m.startsWith('#')) return Number(m.slice(1)) + // is not # syntax but full URL + return Number(m.split('/').slice(-1)[0]) + }) + if (!refs || refs.length === 0) return + + const referee = await models.item.findMany({ + where: { + id: { in: refs }, + // Don't create mentions for your own items + userId: { not: item.userId } + + } + }) + + referee.forEach(async r => { + const data = { + referrerId: item.id, + refereeId: r.id + } + + const mention = await models.itemMention.upsert({ + where: { + referrerId_refereeId: data + }, + update: data, + create: data + }) + + // only send if mention is new to avoid duplicates + if (mention.createdAt.getTime() === mention.updatedAt.getTime()) { + notifyItemMention({ models, referrerItem: item, refereeItem: r }) + } + }) } export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => { diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a43acecd..3dfa4868 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -140,6 +140,22 @@ export default { LIMIT ${LIMIT}` ) } + // item mentions + if (meFull.noteItemMentions) { + itemDrivenQueries.push( + `SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type + FROM "ItemMention" + JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id + JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id + ${whereClause( + '"ItemMention".created_at < $2', + '"Referrer"."userId" <> $1', + '"Referee"."userId" = $1' + )} + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}` + ) + } // Inner union to de-dupe item-driven notifications queries.push( // Only record per item ID @@ -157,6 +173,7 @@ export default { WHEN type = 'Reply' THEN 2 WHEN type = 'FollowActivity' THEN 3 WHEN type = 'TerritoryPost' THEN 4 + WHEN type = 'ItemMention' THEN 5 END ASC )` ) @@ -456,6 +473,9 @@ export default { mention: async (n, args, { models }) => true, item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) }, + ItemMention: { + item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) + }, InvoicePaid: { invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) }, diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 0dccafb1..4ef83037 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -347,6 +347,26 @@ export default { } } + if (user.noteItemMentions) { + const [newMentions] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM "ItemMention" + JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id + JOIN "Item" ON "ItemMention"."referrerId" = "Item".id + ${whereClause( + '"ItemMention".created_at < $2', + '"Item"."userId" <> $1', + '"Referee"."userId" = $1', + await filterClause(me, models), + muteClause(me) + )})`, me.id, lastChecked) + if (newMentions.exists) { + foundNotes() + return true + } + } + if (user.noteForwardedSats) { const [newFwdSats] = await models.$queryRawUnsafe(` SELECT EXISTS( diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 20fc13a4..215f368d 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -43,6 +43,12 @@ export default gql` sortTime: Date! } + type ItemMention { + id: ID! + item: Item! + sortTime: Date! + } + type Invitification { id: ID! invite: Invite! @@ -130,7 +136,7 @@ export default gql` union Notification = Reply | Votification | Mention | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus - | TerritoryPost | TerritoryTransfer | Reminder + | TerritoryPost | TerritoryTransfer | Reminder | ItemMention type Notifications { lastChecked: Date diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 5c0f29db..1e4fd014 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -95,6 +95,7 @@ export default gql` noteItemSats: Boolean! noteJobIndicator: Boolean! noteMentions: Boolean! + noteItemMentions: Boolean! nsfwMode: Boolean! tipDefault: Int! turboTipping: Boolean! @@ -161,6 +162,7 @@ export default gql` noteItemSats: Boolean! noteJobIndicator: Boolean! noteMentions: Boolean! + noteItemMentions: Boolean! nsfwMode: Boolean! tipDefault: Int! turboTipping: Boolean! diff --git a/components/notifications.js b/components/notifications.js index b346b4aa..cd345613 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -54,6 +54,7 @@ function Notification ({ n, fresh }) { (type === 'Votification' && ) || (type === 'ForwardedVotification' && ) || (type === 'Mention' && ) || + (type === 'ItemMention' && ) || (type === 'JobChanged' && ) || (type === 'Reply' && ) || (type === 'SubStatus' && ) || @@ -391,6 +392,26 @@ function Mention ({ n }) { ) } +function ItemMention ({ n }) { + return ( + <> + + your item was mentioned in + +
+ {n.item?.title + ? + : ( +
+ + + +
)} +
+ + ) +} + function JobChanged ({ n }) { return ( <> diff --git a/components/text.js b/components/text.js index d6dd4a7c..355e6434 100644 --- a/components/text.js +++ b/components/text.js @@ -23,6 +23,7 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants' import isEqual from 'lodash/isEqual' import UserPopover from './user-popover' import ItemPopover from './item-popover' +import ref from '@/lib/remark-ref2link' export function SearchText ({ text }) { return ( @@ -298,7 +299,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o }, img: Img }} - remarkPlugins={[gfm, mention, sub]} + remarkPlugins={[gfm, mention, sub, ref]} rehypePlugins={[rehypeInlineCodeProperty]} > {children} diff --git a/fragments/notifications.js b/fragments/notifications.js index 76706701..01c35191 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql` text } } + ... on ItemMention { + id + sortTime + item { + ...ItemFullFields + text + } + } ... on Votification { id sortTime diff --git a/fragments/users.js b/fragments/users.js index e6bfd085..723f67b5 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -38,6 +38,7 @@ export const ME = gql` noteItemSats noteJobIndicator noteMentions + noteItemMentions sats tipDefault tipPopover @@ -73,6 +74,7 @@ export const SETTINGS_FIELDS = gql` noteEarning noteAllDescendants noteMentions + noteItemMentions noteDeposits noteWithdrawals noteInvites diff --git a/lib/remark-ref2link.js b/lib/remark-ref2link.js new file mode 100644 index 00000000..ef8a5d2e --- /dev/null +++ b/lib/remark-ref2link.js @@ -0,0 +1,26 @@ +import { findAndReplace } from 'mdast-util-find-and-replace' + +const refRegex = /#(\d+(\/(edit|related|ots))?)/gi + +export default function ref (options) { + return function transformer (tree) { + findAndReplace( + tree, + [ + [refRegex, replaceRef] + ], + { ignore: ['link', 'linkReference'] } + ) + } + + function replaceRef (value, itemId, match) { + const node = { type: 'text', value } + + return { + type: 'link', + title: null, + url: `/items/${itemId}`, + children: [node] + } + } +} diff --git a/lib/webPush.js b/lib/webPush.js index 0b4344cb..dcab5a6d 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -38,6 +38,7 @@ const createUserFilter = (tag) => { const tagMap = { REPLY: 'noteAllDescendants', MENTION: 'noteMentions', + ITEM_MENTION: 'noteItemMentions', TIP: 'noteItemSats', FORWARDEDTIP: 'noteForwardedSats', REFERRAL: 'noteInvites', @@ -262,6 +263,27 @@ export const notifyMention = async ({ models, userId, item }) => { } } +export const notifyItemMention = async ({ models, referrerItem, refereeItem }) => { + try { + const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId }) + if (!muted) { + const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } }) + + // replace full links to # syntax as rendered on site + const body = referrerItem.text.replace(new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/(\\d+)`, 'gi'), '#$1') + + await sendUserNotification(refereeItem.userId, { + title: `@${referrer.name} mentioned one of your items`, + body, + item: referrerItem, + tag: 'ITEM_MENTION' + }) + } + } catch (err) { + console.error(err) + } +} + export const notifyReferral = async (userId) => { try { await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }) diff --git a/pages/settings/index.js b/pages/settings/index.js index 38384755..416d2811 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -120,6 +120,7 @@ export default function Settings ({ ssrData }) { noteEarning: settings?.noteEarning, noteAllDescendants: settings?.noteAllDescendants, noteMentions: settings?.noteMentions, + noteItemMentions: settings?.noteItemMentions, noteDeposits: settings?.noteDeposits, noteWithdrawals: settings?.noteWithdrawals, noteInvites: settings?.noteInvites, @@ -280,6 +281,11 @@ export default function Settings ({ ssrData }) { name='noteMentions' groupClassName='mb-0' /> +