Merge pull request #1705 from Human-Connection/1703-add-vue-apollo-subsriptions

feat: 🍰 Set up Vue-Apollo Subscriptions
This commit is contained in:
mattwr18 2020-02-12 18:02:05 +01:00 committed by GitHub
commit 50ec01d718
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 256 additions and 61 deletions

View File

@ -60,9 +60,11 @@
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1",
"graphql-redis-subscriptions": "^2.1.2",
"graphql-shield": "~7.0.11",
"graphql-tag": "~2.10.3",
"helmet": "~3.21.2",
"ioredis": "^4.14.1",
"jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.14",
@ -96,6 +98,7 @@
"request": "~2.88.2",
"sanitize-html": "~1.21.1",
"slug": "~2.1.1",
"subscriptions-transport-ws": "^0.9.16",
"trunc-html": "~1.1.2",
"uuid": "~3.4.0",
"validator": "^12.2.0",

View File

@ -23,6 +23,9 @@ const {
NEO4J_PASSWORD = 'neo4j',
CLIENT_URI = 'http://localhost:3000',
GRAPHQL_URI = 'http://localhost:4000',
REDIS_DOMAIN,
REDIS_PORT,
REDIS_PASSWORD,
} = env
export const requiredConfigs = {
@ -61,7 +64,7 @@ export const developmentConfigs = {
}
export const sentryConfigs = { SENTRY_DSN_BACKEND, COMMIT }
export const redisConfiig = { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD }
export default {
...requiredConfigs,
...smtpConfigs,
@ -69,4 +72,5 @@ export default {
...serverConfigs,
...developmentConfigs,
...sentryConfigs,
...redisConfiig,
}

View File

@ -1,9 +1,11 @@
import createServer from './server'
import CONFIG from './config'
const { app } = createServer()
const { server, httpServer } = createServer()
const url = new URL(CONFIG.GRAPHQL_URI)
app.listen({ port: url.port }, () => {
httpServer.listen({ port: url.port }, () => {
/* eslint-disable-next-line no-console */
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
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}`)
})

View File

@ -1,5 +1,6 @@
import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
import { pubsub, NOTIFICATION_ADDED } from '../../server'
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
@ -52,34 +53,48 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
WHERE user.id in $idsOfUsers
AND NOT (user)-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
WITH post AS resource, notification, user
`
break
}
case 'mentioned_in_comment': {
mentionedCypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(commenter: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)-[:BLOCKED]-(author)
AND NOT (user)-[:BLOCKED]-(commenter)
AND NOT (user)-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
WITH comment AS resource, notification, user
`
break
}
}
mentionedCypher += `
WITH notification, user, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime())
RETURN notification {.*, from: finalResource, to: properties(user)}
`
const session = context.driver.session()
try {
await session.writeTransaction(transaction => {
return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
const writeTxResultPromise = session.writeTransaction(async transaction => {
const notificationTransactionResponse = await transaction.run(mentionedCypher, {
id,
idsOfUsers,
reason,
})
return notificationTransactionResponse.records.map(record => record.get('notification'))
})
try {
const [notification] = await writeTxResultPromise
return pubsub.publish(NOTIFICATION_ADDED, { notificationAdded: notification })
} catch (error) {
throw new Error(error)
} finally {
session.close()
}
@ -88,24 +103,26 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => {
await validateNotifyUsers(label, reason)
const session = context.driver.session()
const writeTxResultPromise = await session.writeTransaction(async transaction => {
const notificationTransactionResponse = await transaction.run(
`
MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
SET notification.read = FALSE
SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime()))
SET notification.updatedAt = toString(datetime())
WITH notification, postAuthor, post,
comment {.*, __typename: labels(comment)[0], author: properties(commenter), post: post {.*, author: properties(postAuthor) } } AS finalResource
RETURN notification {.*, from: finalResource, to: properties(postAuthor)}
`,
{ commentId, postAuthorId, reason },
)
return notificationTransactionResponse.records.map(record => record.get('notification'))
})
try {
await session.writeTransaction(async transaction => {
await transaction.run(
`
MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`,
{ commentId, postAuthorId, reason },
)
})
const [notification] = await writeTxResultPromise
return pubsub.publish(NOTIFICATION_ADDED, { notificationAdded: notification })
} finally {
session.close()
}

View File

@ -1,21 +1,18 @@
import log from './helpers/databaseLogger'
const resourceTypes = ['Post', 'Comment']
const transformReturnType = record => {
return {
...record.get('notification').properties,
from: {
__typename: record.get('resource').labels.find(l => resourceTypes.includes(l)),
...record.get('resource').properties,
},
to: {
...record.get('user').properties,
},
}
}
import { withFilter } from 'graphql-subscriptions'
import { pubsub, NOTIFICATION_ADDED } from '../../server'
export default {
Subscription: {
notificationAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(NOTIFICATION_ADDED),
(payload, variables) => {
return payload.notificationAdded.to.id === variables.userId
},
),
},
},
Query: {
notifications: async (_parent, args, context, _resolveInfo) => {
const { user: currentUser } = context
@ -51,10 +48,10 @@ export default {
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause}
WITH user, notification, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] as authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] as posts
[(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} as finalResource
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource
RETURN notification {.*, from: finalResource, to: properties(user)}
${orderByClause}
${offset} ${limit}
@ -81,12 +78,19 @@ export default {
`
MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
RETURN resource, notification, user
WITH user, notification, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource
RETURN notification {.*, from: finalResource, to: properties(user)}
`,
{ resourceId: args.id, id: currentUser.id },
)
log(markNotificationAsReadTransactionResponse)
return markNotificationAsReadTransactionResponse.records.map(transformReturnType)
return markNotificationAsReadTransactionResponse.records.map(record =>
record.get('notification'),
)
})
try {
const [notifications] = await writeTxResultPromise

View File

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

View File

@ -1,4 +1,5 @@
import express from 'express'
import http from 'http'
import helmet from 'helmet'
import { ApolloServer } from 'apollo-server-express'
import CONFIG from './config'
@ -7,12 +8,35 @@ import { getNeode, getDriver } from './db/neo4j'
import decode from './jwt/decode'
import schema from './schema'
import webfinger from './activitypub/routes/webfinger'
import { RedisPubSub } from 'graphql-redis-subscriptions'
import { PubSub } from 'graphql-subscriptions'
import Redis from 'ioredis'
import bodyParser from 'body-parser'
export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED'
const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG
let prodPubsub, devPubsub
const options = {
host: REDIS_DOMAIN,
port: REDIS_PORT,
password: REDIS_PASSWORD,
retryStrategy: times => {
return Math.min(times * 50, 2000)
},
}
if (options.host && options.port && options.password) {
prodPubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
})
} else {
devPubsub = new PubSub()
}
export const pubsub = prodPubsub || devPubsub
const driver = getDriver()
const neode = getNeode()
export const context = async ({ req }) => {
const getContext = async req => {
const user = await decode(driver, req.headers.authorization)
return {
driver,
@ -24,11 +48,24 @@ export const context = async ({ req }) => {
},
}
}
export const context = async options => {
const { connection, req } = options
if (connection) {
return connection.context
} else {
return getContext(req)
}
}
const createServer = options => {
const defaults = {
context,
schema: middleware(schema),
subscriptions: {
onConnect: (connectionParams, webSocket) => {
return getContext(connectionParams)
},
},
debug: !!CONFIG.DEBUG,
tracing: !!CONFIG.DEBUG,
formatError: error => {
@ -49,8 +86,10 @@ const createServer = options => {
app.use(bodyParser.json({ limit: '10mb' }))
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }))
server.applyMiddleware({ app, path: '/' })
const httpServer = http.createServer(app)
server.installSubscriptionHandlers(httpServer)
return { server, app }
return { server, httpServer, app }
}
export default createServer

View File

@ -2774,6 +2774,11 @@ clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
cluster-key-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d"
integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -3254,6 +3259,11 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
denque@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf"
integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==
depd@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@ -4476,6 +4486,15 @@ graphql-middleware@~4.0.2:
dependencies:
graphql-tools "^4.0.5"
graphql-redis-subscriptions@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/graphql-redis-subscriptions/-/graphql-redis-subscriptions-2.1.2.tgz#9c1b744bace0c6ba99dd0ebafe0148cad1df3301"
integrity sha512-l69KbGxyYfVHxvE+Dzv9/hXg/q+Xnjfx1JsrJD6ikePuSsNaCSNxr+MubSTNF3Gt3C/+JZs4FaWImFeK/+X2og==
dependencies:
iterall "^1.2.2"
optionalDependencies:
ioredis "^4.6.3"
graphql-shield@~7.0.11:
version "7.0.11"
resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.11.tgz#78d49f346326be71090d35d8f5843da9ee8136e2"
@ -4920,6 +4939,21 @@ invariant@^2.2.2, invariant@^2.2.4:
dependencies:
loose-envify "^1.0.0"
ioredis@^4.14.1, ioredis@^4.6.3:
version "4.14.1"
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.14.1.tgz#b73ded95fcf220f106d33125a92ef6213aa31318"
integrity sha512-94W+X//GHM+1GJvDk6JPc+8qlM7Dul+9K+lg3/aHixPN7ZGkW6qlvX0DG6At9hWtH2v3B32myfZqWoANUJYGJA==
dependencies:
cluster-key-slot "^1.1.0"
debug "^4.1.1"
denque "^1.1.0"
lodash.defaults "^4.2.0"
lodash.flatten "^4.4.0"
redis-commands "1.5.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.0.1"
ip-regex@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd"
@ -5960,11 +5994,21 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
lodash.escaperegexp@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=
lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@ -7489,6 +7533,23 @@ realpath-native@^1.1.0:
dependencies:
util.promisify "^1.0.0"
redis-commands@1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785"
integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=
dependencies:
redis-errors "^1.0.0"
referrer-policy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e"
@ -8196,6 +8257,11 @@ stacktrace-js@^2.0.0:
stack-generator "^2.0.1"
stacktrace-gps "^3.0.1"
standard-as-callback@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.0.1.tgz#ed8bb25648e15831759b6023bdb87e6b60b38126"
integrity sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg==
static-extend@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"

View File

@ -22,10 +22,9 @@
</template>
<script>
import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy'
import { notificationQuery, markAsReadMutation, notificationAdded } from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown'
import NotificationList from '../NotificationList/NotificationList'
@ -59,6 +58,9 @@ export default {
},
},
computed: {
...mapGetters({
user: 'auth/user',
}),
unreadNotificationsCount() {
const result = this.notifications.reduce((count, notification) => {
return notification.read ? count : count + 1
@ -77,11 +79,25 @@ export default {
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: {
document: notificationAdded(),
variables() {
return {
userId: this.user.id,
}
},
updateQuery: (previousResult, { subscriptionData }) => {
const {
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) {
this.$toast.error(error.message)

View File

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

View File

@ -71,6 +71,7 @@ export const notificationQuery = i18n => {
read
reason
createdAt
updatedAt
from {
__typename
... on Post {
@ -109,6 +110,7 @@ export const markAsReadMutation = i18n => {
read
reason
createdAt
updatedAt
from {
__typename
... on Post {
@ -132,6 +134,44 @@ export const markAsReadMutation = i18n => {
`
}
export const notificationAdded = () => {
return gql`
${userFragment}
${commentFragment}
${postFragment}
subscription notifications($userId: ID!) {
notificationAdded(userId: $userId) {
id
read
reason
createdAt
updatedAt
from {
__typename
... on Post {
...post
author {
...user
}
}
... on Comment {
...comment
author {
...user
}
post {
...post
author {
...user
}
}
}
}
}
}
`
}
export const followUserMutation = i18n => {
return gql`
${userFragment}

View File

@ -9,6 +9,7 @@ export default ({ app }) => {
const backendUrl = process.env.GRAPHQL_URI || 'http://localhost:4000'
return {
wsEndpoint: process.env.WEBSOCKETS_URI || 'ws://localhost:4000/graphql',
httpEndpoint: process.server ? backendUrl : '/api',
httpLinkOptions: {
credentials: 'same-origin',