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 #<id> syntax in push notifications * Refactor mention code into separate functions
This commit is contained in:
parent
d454bbdb72
commit
2597eb56f3
|
@ -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/)(?<id>\\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 #<id> 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 }) => {
|
||||
|
|
|
@ -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 })
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -54,6 +54,7 @@ function Notification ({ n, fresh }) {
|
|||
(type === 'Votification' && <Votification n={n} />) ||
|
||||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
||||
(type === 'Mention' && <Mention n={n} />) ||
|
||||
(type === 'ItemMention' && <ItemMention n={n} />) ||
|
||||
(type === 'JobChanged' && <JobChanged n={n} />) ||
|
||||
(type === 'Reply' && <Reply n={n} />) ||
|
||||
(type === 'SubStatus' && <SubStatus n={n} />) ||
|
||||
|
@ -391,6 +392,26 @@ function Mention ({ n }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ItemMention ({ n }) {
|
||||
return (
|
||||
<>
|
||||
<small className='fw-bold text-info ms-2'>
|
||||
your item was mentioned in
|
||||
</small>
|
||||
<div>
|
||||
{n.item?.title
|
||||
? <Item item={n.item} />
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<RootProvider root={n.item.root}>
|
||||
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
|
||||
</RootProvider>
|
||||
</div>)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function JobChanged ({ n }) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql`
|
|||
text
|
||||
}
|
||||
}
|
||||
... on ItemMention {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFullFields
|
||||
text
|
||||
}
|
||||
}
|
||||
... on Votification {
|
||||
id
|
||||
sortTime
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 #<id> 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' })
|
||||
|
|
|
@ -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'
|
||||
/>
|
||||
<Checkbox
|
||||
label='someone mentions one of my items'
|
||||
name='noteItemMentions'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
<Checkbox
|
||||
label='there is a new job'
|
||||
name='noteJobIndicator'
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ItemMention" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"referrerId" INTEGER NOT NULL,
|
||||
"refereeId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ItemMention_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.created_at_index" ON "ItemMention"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.referrerId_index" ON "ItemMention"("referrerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.refereeId_index" ON "ItemMention"("refereeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ItemMention.referrerId_refereeId_unique" ON "ItemMention"("referrerId", "refereeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "noteItemMentions" BOOLEAN NOT NULL DEFAULT true;
|
|
@ -44,6 +44,7 @@ model User {
|
|||
noteInvites Boolean @default(true)
|
||||
noteItemSats Boolean @default(true)
|
||||
noteMentions Boolean @default(true)
|
||||
noteItemMentions Boolean @default(true)
|
||||
noteForwardedSats Boolean @default(true)
|
||||
lastCheckedJobs DateTime?
|
||||
noteJobIndicator Boolean @default(true)
|
||||
|
@ -411,6 +412,8 @@ model Item {
|
|||
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
|
||||
actions ItemAct[]
|
||||
mentions Mention[]
|
||||
referrer ItemMention[] @relation("referrer")
|
||||
referee ItemMention[] @relation("referee")
|
||||
PollOption PollOption[]
|
||||
PollVote PollVote[]
|
||||
ThreadSubscription ThreadSubscription[]
|
||||
|
@ -661,6 +664,21 @@ model Mention {
|
|||
@@index([userId], map: "Mention.userId_index")
|
||||
}
|
||||
|
||||
model ItemMention {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
referrerId Int
|
||||
refereeId Int
|
||||
referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade)
|
||||
refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique")
|
||||
@@index([createdAt], map: "ItemMention.created_at_index")
|
||||
@@index([referrerId], map: "ItemMention.referrerId_index")
|
||||
@@index([refereeId], map: "ItemMention.refereeId_index")
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
|
|
@ -114,7 +114,7 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
|||
// merge notifications into single notification payload
|
||||
// ---
|
||||
// tags that need to know the amount of notifications with same tag for merging
|
||||
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
||||
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
||||
// tags that need to know the sum of sats of notifications with same tag for merging
|
||||
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
||||
// this should reflect the amount of notifications that were already merged before
|
||||
|
@ -143,6 +143,8 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
|||
title = `you have ${amount} new replies`
|
||||
} else if (compareTag === 'MENTION') {
|
||||
title = `you were mentioned ${amount} times`
|
||||
} else if (compareTag === 'ITEM_MENTION') {
|
||||
title = `your items were mentioned ${amount} times`
|
||||
} else if (compareTag === 'REFERRAL') {
|
||||
title = `${amount} stackers joined via your referral links`
|
||||
} else if (compareTag === 'INVITE') {
|
||||
|
|
Loading…
Reference in New Issue