Subscribe to notifications/remove polling

- We want to publish when a notification occurs for a specific user, not
  have the client poll the backend for ever user every minute.

- Co-authored-by: @Tirokk <wolle.huss@pjannto.com>
This commit is contained in:
mattwr18 2020-02-05 17:37:38 +01:00
parent 04f0467d2d
commit 2f43069ea0
13 changed files with 98 additions and 75 deletions

View File

@ -1,10 +1,11 @@
import createServer from './server' import createServer from './server'
import CONFIG from './config' import CONFIG from './config'
const { app, server, httpServer } = createServer() const { server, httpServer } = createServer()
const url = new URL(CONFIG.GRAPHQL_URI) const url = new URL(CONFIG.GRAPHQL_URI)
httpServer.listen({ port: url.port }, () => { httpServer.listen({ port: url.port }, () => {
/* eslint-disable-next-line no-console */ /* eslint-disable-next-line no-console */
console.log(`🚀 Server ready at http://localhost:${url.port}${server.graphqlPath}`) console.log(`🚀 Server ready at http://localhost:${url.port}${server.graphqlPath}`)
/* eslint-disable-next-line no-console */
console.log(`🚀 Subscriptions ready at ws://localhost:${url.port}${server.subscriptionsPath}`) console.log(`🚀 Subscriptions ready at ws://localhost:${url.port}${server.subscriptionsPath}`)
}) })

View File

@ -1,9 +1,10 @@
import extractMentionedUsers from './mentions/extractMentionedUsers' import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware' import { validateNotifyUsers } from '../validation/validationMiddleware'
import { PubSub } from 'apollo-server' import {
pubsub,
const pubsub = new PubSub() NOTIFICATION_ADDED,
const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' transformReturnType,
} from '../../schema/resolvers/notifications'
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content) const idsOfUsers = extractMentionedUsers(args.content)
@ -56,6 +57,7 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)-[:BLOCKED]-(author) AND NOT (user)-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
WITH notification, post AS resource, user
` `
break break
} }
@ -67,6 +69,7 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
AND NOT (user)-[:BLOCKED]-(author) AND NOT (user)-[:BLOCKED]-(author)
AND NOT (user)-[:BLOCKED]-(postAuthor) AND NOT (user)-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
WITH notification, comment AS resource, user
` `
break break
} }
@ -78,12 +81,16 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
WHEN notification.createdAt IS NULL WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime()) THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime()) SET notification.updatedAt = toString(datetime())
RETURN notification RETURN notification, resource, user, labels(resource)[0] AS type
` `
const session = context.driver.session() const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async transaction => { const writeTxResultPromise = session.writeTransaction(async transaction => {
const notificationTransactionResponse = await transaction.run(mentionedCypher, { id, idsOfUsers, reason }) const notificationTransactionResponse = await transaction.run(mentionedCypher, {
return notificationTransactionResponse.records.map(record => record.get('notification').properties) id,
idsOfUsers,
reason,
})
return notificationTransactionResponse.records.map(transformReturnType)
}) })
try { try {
const [notification] = await writeTxResultPromise const [notification] = await writeTxResultPromise

View File

@ -1,16 +1,14 @@
import log from './helpers/databaseLogger' import log from './helpers/databaseLogger'
import { PubSub } from 'apollo-server' import { PubSub } from 'apollo-server'
import { withFilter } from 'graphql-subscriptions'
const pubsub = new PubSub() export const pubsub = new PubSub()
const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
export const transformReturnType = record => {
const resourceTypes = ['Post', 'Comment']
const transformReturnType = record => {
return { return {
...record.get('notification').properties, ...record.get('notification').properties,
from: { from: {
__typename: record.get('resource').labels.find(l => resourceTypes.includes(l)), __typename: record.get('type'),
...record.get('resource').properties, ...record.get('resource').properties,
}, },
to: { to: {
@ -22,7 +20,12 @@ const transformReturnType = record => {
export default { export default {
Subscription: { Subscription: {
notificationAdded: { notificationAdded: {
subscribe: () => pubsub.asyncIterator([NOTIFICATION_ADDED]), subscribe: withFilter(
() => pubsub.asyncIterator(NOTIFICATION_ADDED),
(payload, variables) => {
return payload.notificationAdded.to.id === variables.userId
},
),
}, },
}, },
Query: { Query: {
@ -90,7 +93,7 @@ export default {
` `
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE SET notification.read = TRUE
RETURN resource, notification, user RETURN resource, notification, user, labels(resource)[0] AS type
`, `,
{ resourceId: args.id, id: currentUser.id }, { resourceId: args.id, id: currentUser.id },
) )

View File

@ -1,16 +1,14 @@
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { UserInputError } from 'apollo-server' import { UserInputError, PubSub } from 'apollo-server'
import fileUpload from './fileUpload' import fileUpload from './fileUpload'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
import { filterForMutedUsers } from './helpers/filterForMutedUsers' import { filterForMutedUsers } from './helpers/filterForMutedUsers'
import { PubSub } from 'apollo-server'
const pubsub = new PubSub() const pubsub = new PubSub()
const POST_ADDED = 'POST_ADDED' const POST_ADDED = 'POST_ADDED'
const maintainPinnedPosts = params => { const maintainPinnedPosts = params => {
const pinnedPostFilter = { pinned: true } const pinnedPostFilter = { pinned: true }
if (isEmpty(params.filter)) { if (isEmpty(params.filter)) {

View File

@ -30,3 +30,7 @@ type Query {
type Mutation { type Mutation {
markAsRead(id: ID!): NOTIFIED markAsRead(id: ID!): NOTIFIED
} }
type Subscription {
notificationAdded(userId: ID!): NOTIFIED
}

View File

@ -245,3 +245,7 @@ type Query {
""" """
) )
} }
type Subscription {
postAdded: Post
}

View File

@ -1,4 +0,0 @@
type Subscription {
postAdded: Post
notificationAdded: NOTIFIED
}

View File

@ -3,7 +3,6 @@ import http from 'http'
import helmet from 'helmet' import helmet from 'helmet'
import { ApolloServer } from 'apollo-server-express' import { ApolloServer } from 'apollo-server-express'
import CONFIG from './config' import CONFIG from './config'
import middleware from './middleware' import middleware from './middleware'
import { getNeode, getDriver } from './db/neo4j' import { getNeode, getDriver } from './db/neo4j'
@ -11,11 +10,10 @@ import decode from './jwt/decode'
import schema from './schema' import schema from './schema'
import webfinger from './activitypub/routes/webfinger' import webfinger from './activitypub/routes/webfinger'
const driver = getDriver() const driver = getDriver()
const neode = getNeode() const neode = getNeode()
const getContext = async (req) => { const getContext = async req => {
const user = await decode(driver, req.headers.authorization) const user = await decode(driver, req.headers.authorization)
return { return {
driver, driver,
@ -27,7 +25,7 @@ const getContext = async (req) => {
}, },
} }
} }
export const context = async (options) => { export const context = async options => {
const { connection, req } = options const { connection, req } = options
if (connection) { if (connection) {
return connection.context return connection.context
@ -63,9 +61,8 @@ const createServer = options => {
app.use('/.well-known/', webfinger()) app.use('/.well-known/', webfinger())
app.use(express.static('public')) app.use(express.static('public'))
server.applyMiddleware({ app, path: '/' }) server.applyMiddleware({ app, path: '/' })
const httpServer = http.createServer(app); const httpServer = http.createServer(app)
server.installSubscriptionHandlers(httpServer); server.installSubscriptionHandlers(httpServer)
return { server, httpServer, app } return { server, httpServer, app }
} }

View File

@ -22,13 +22,12 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy' import unionBy from 'lodash/unionBy'
import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications' import { notificationQuery, markAsReadMutation, notificationAdded } from '~/graphql/User'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon' import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import NotificationList from '../NotificationList/NotificationList' import NotificationList from '../NotificationList/NotificationList'
import { notificationAdded } from '~/graphql/User'
export default { export default {
name: 'NotificationMenu', name: 'NotificationMenu',
@ -59,6 +58,9 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({
user: 'auth/user',
}),
unreadNotificationsCount() { unreadNotificationsCount() {
const result = this.notifications.reduce((count, notification) => { const result = this.notifications.reduce((count, notification) => {
return notification.read ? count : count + 1 return notification.read ? count : count + 1
@ -77,17 +79,24 @@ export default {
orderBy: 'updatedAt_desc', orderBy: 'updatedAt_desc',
} }
}, },
// pollInterval: NOTIFICATIONS_POLL_INTERVAL,
// update({ notifications }) {
// return unionBy(notifications, this.notifications, notification => notification.id).sort(
// (a, b) => new Date(b.createdAt) - new Date(a.createdAt),
// )
// },
subscribeToMore: { subscribeToMore: {
document: notificationAdded(), document: notificationAdded(),
variables() {
return {
userId: this.user.id,
}
},
updateQuery: (previousResult, { subscriptionData }) => { updateQuery: (previousResult, { subscriptionData }) => {
const { data: { notificationAdded: newNotification } } = subscriptionData const {
return { notifications: [newNotification, ...previousResult.notifications] } data: { notificationAdded: newNotification },
} = subscriptionData
return {
notifications: unionBy(
[newNotification],
previousResult.notifications,
notification => notification.id,
).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)),
}
}, },
}, },
error(error) { error(error) {

View File

@ -1 +0,0 @@
export const NOTIFICATIONS_POLL_INTERVAL = 60000

View File

@ -70,6 +70,7 @@ export const notificationQuery = i18n => {
read read
reason reason
createdAt createdAt
updatedAt
from { from {
__typename __typename
... on Post { ... on Post {
@ -108,6 +109,7 @@ export const markAsReadMutation = i18n => {
read read
reason reason
createdAt createdAt
updatedAt
from { from {
__typename __typename
... on Post { ... on Post {
@ -137,12 +139,13 @@ export const notificationAdded = () => {
${commentFragment} ${commentFragment}
${postFragment} ${postFragment}
subscription notifications { subscription notifications($userId: ID!) {
notificationAdded { notificationAdded(userId: $userId) {
id id
read read
reason reason
createdAt createdAt
updatedAt
from { from {
__typename __typename
... on Post { ... on Post {

View File

@ -78,7 +78,6 @@ import gql from 'graphql-tag'
import { import {
userFragment, userFragment,
postFragment, postFragment,
commentFragment,
postCountsFragment, postCountsFragment,
userCountsFragment, userCountsFragment,
locationAndBadgesFragment, locationAndBadgesFragment,
@ -243,12 +242,15 @@ export default {
...locationAndBadges ...locationAndBadges
} }
} }
}`, }
`,
updateQuery: (previousResult, { subscriptionData }) => { updateQuery: (previousResult, { subscriptionData }) => {
const { data: { postAdded: newPost } } = subscriptionData const {
data: { postAdded: newPost },
} = subscriptionData
return { Post: [newPost, ...previousResult.Post] } return { Post: [newPost, ...previousResult.Post] }
}, },
} },
}, },
}, },
} }

View File

@ -17,7 +17,7 @@ export default ({ app }) => {
credentials: true, credentials: true,
tokenName: 'human-connection-token', tokenName: 'human-connection-token',
persisting: false, persisting: false,
websocketsOnly: true, websocketsOnly: false,
cache: new InMemoryCache({ fragmentMatcher }), cache: new InMemoryCache({ fragmentMatcher }),
} }
} }