Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Item mention notifications #1208

Merged
merged 8 commits into from
Jun 3, 2024
Merged
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
111 changes: 81 additions & 30 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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 }
}
})
await createUserMentions(item, models)
} catch (e) {
console.error('user mention failure', e)
}

users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
// item mentions
try {
await createItemMentions(item, models)
} catch (e) {
console.error('item mention failure', e)
}
}

const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
const createUserMentions = async (item, models) => {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (!mentions || mentions.length === 0) return

// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention({ models, userId: user.id, item })
}
})
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
} catch (e) {
console.error('mention failure', e)
}
})

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 }) => {
Expand Down
20 changes: 20 additions & 0 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)`
)
Expand Down Expand Up @@ -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 })
},
Expand Down
20 changes: 20 additions & 0 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion api/typeDefs/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export default gql`
sortTime: Date!
}

type ItemMention {
id: ID!
item: Item!
sortTime: Date!
}

type Invitification {
id: ID!
invite: Invite!
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default gql`
noteItemSats: Boolean!
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
turboTipping: Boolean!
Expand Down Expand Up @@ -161,6 +162,7 @@ export default gql`
noteItemSats: Boolean!
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
turboTipping: Boolean!
Expand Down
21 changes: 21 additions & 0 deletions components/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} />) ||
Expand Down Expand Up @@ -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 (
<>
Expand Down
3 changes: 2 additions & 1 deletion components/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -284,7 +285,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}
Expand Down
8 changes: 8 additions & 0 deletions fragments/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql`
text
}
}
... on ItemMention {
id
sortTime
item {
...ItemFullFields
text
}
}
... on Votification {
id
sortTime
Expand Down
2 changes: 2 additions & 0 deletions fragments/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const ME = gql`
noteItemSats
noteJobIndicator
noteMentions
noteItemMentions
sats
tipDefault
tipPopover
Expand Down Expand Up @@ -73,6 +74,7 @@ export const SETTINGS_FIELDS = gql`
noteEarning
noteAllDescendants
noteMentions
noteItemMentions
noteDeposits
noteWithdrawals
noteInvites
Expand Down
26 changes: 26 additions & 0 deletions lib/remark-ref2link.js
Original file line number Diff line number Diff line change
@@ -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]
}
}
}
22 changes: 22 additions & 0 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const createUserFilter = (tag) => {
const tagMap = {
REPLY: 'noteAllDescendants',
MENTION: 'noteMentions',
ITEM_MENTION: 'noteItemMentions',
TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats',
REFERRAL: 'noteInvites',
Expand Down Expand Up @@ -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' })
Expand Down
Loading
Loading