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' /> +