diff --git a/README.md b/README.md
index 7877f95f..7e71e476 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
The site is written in javascript using Next.js, a React framework. The backend API is provided via graphql. The database is postgresql modelled with prisma. We use lnd for the lightning node which we connect to through a tor http tunnel. A customized Bootstrap theme is used for styling.
# processes
-There are two. 1. the web app and 2. walletd, which checks and polls lnd for all pending invoice/withdrawl statuses in case the web process dies.
+There are two. 1. the web app and 2. walletd, which checks and polls lnd for all pending invoice/withdrawal statuses in case the web process dies.
# wallet transaction safety
To ensure user balances are kept sane, all wallet updates are run in serializable transactions at the database level. Because prisma has relatively poor support for transactions all wallet touching code is written in plpgsql stored procedures and can be found in the prisma/migrations folder.
diff --git a/api/models/index.js b/api/models/index.js
index 67d8b6c3..e79851f2 100644
--- a/api/models/index.js
+++ b/api/models/index.js
@@ -1,6 +1,8 @@
import { PrismaClient } from '@prisma/client'
-const prisma = global.prisma || new PrismaClient()
+const prisma = global.prisma || new PrismaClient({
+ log: ['warn', 'error']
+})
if (process.env.NODE_ENV === 'development') global.prisma = prisma
diff --git a/api/resolvers/cursor.js b/api/resolvers/cursor.js
new file mode 100644
index 00000000..d9cdfd63
--- /dev/null
+++ b/api/resolvers/cursor.js
@@ -0,0 +1,16 @@
+export const LIMIT = 21
+
+export function decodeCursor (cursor) {
+ if (!cursor) {
+ return { offset: 0, time: new Date() }
+ } else {
+ const res = JSON.parse(Buffer.from(cursor, 'base64'))
+ res.time = new Date(res.time)
+ return res
+ }
+}
+
+export function nextCursorEncoded (cursor) {
+ cursor.offset += LIMIT
+ return Buffer.from(JSON.stringify(cursor)).toString('base64')
+}
diff --git a/api/resolvers/index.js b/api/resolvers/index.js
index 484e2f7d..91a04e7b 100644
--- a/api/resolvers/index.js
+++ b/api/resolvers/index.js
@@ -3,5 +3,6 @@ import message from './message'
import item from './item'
import wallet from './wallet'
import lnurl from './lnurl'
+import notifications from './notifications'
-export default [user, item, message, wallet, lnurl]
+export default [user, item, message, wallet, lnurl, notifications]
diff --git a/api/resolvers/item.js b/api/resolvers/item.js
index 65b4fcff..20b5cac5 100644
--- a/api/resolvers/item.js
+++ b/api/resolvers/item.js
@@ -1,8 +1,7 @@
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import { ensureProtocol } from '../../lib/url'
import serialize from './serial'
-
-const LIMIT = 21
+import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor'
async function comments (models, id) {
const flat = await models.$queryRaw(`
@@ -20,21 +19,6 @@ async function comments (models, id) {
return nestComments(flat, id)[0]
}
-function decodeCursor (cursor) {
- if (!cursor) {
- return { offset: 0, time: new Date() }
- } else {
- const res = JSON.parse(Buffer.from(cursor, 'base64'))
- res.time = new Date(res.time)
- return res
- }
-}
-
-function nextCursorEncoded (cursor) {
- cursor.offset += LIMIT
- return Buffer.from(JSON.stringify(cursor)).toString('base64')
-}
-
export default {
Query: {
moreItems: async (parent, { sort, cursor, userId }, { me, models }) => {
@@ -77,52 +61,33 @@ export default {
},
moreFlatComments: async (parent, { cursor, userId }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
- let comments
- if (userId) {
- comments = await models.$queryRaw(`
- ${SELECT}
- FROM "Item"
- WHERE "userId" = $1 AND "parentId" IS NOT NULL
- AND created_at <= $2
- ORDER BY created_at DESC
- OFFSET $3
- LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
- } else {
- if (!me) {
- throw new AuthenticationError('you must be logged in')
- }
- comments = await models.$queryRaw(`
- ${SELECT}
- From "Item"
- JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
- AND "Item"."userId" <> $1 AND "Item".created_at <= $2
- ORDER BY "Item".created_at DESC
- OFFSET $3
- LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
+
+ if (!userId) {
+ throw new UserInputError('must supply userId', { argumentName: 'userId' })
}
+
+ const comments = await models.$queryRaw(`
+ ${SELECT}
+ FROM "Item"
+ WHERE "userId" = $1 AND "parentId" IS NOT NULL
+ AND created_at <= $2
+ ORDER BY created_at DESC
+ OFFSET $3
+ LIMIT ${LIMIT}`, Number(userId), decodedCursor.time, decodedCursor.offset)
+
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
- notifications: async (parent, args, { me, models }) => {
- if (!me) {
- throw new AuthenticationError('you must be logged in')
- }
-
- return await models.$queryRaw(`
- ${SELECT}
- From "Item"
- JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
- AND "Item"."userId" <> $1
- ORDER BY "Item".created_at DESC`, me.id)
- },
item: async (parent, { id }, { models }) => {
const [item] = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE id = $1`, Number(id))
- item.comments = comments(models, id)
+ if (item) {
+ item.comments = comments(models, id)
+ }
return item
},
userComments: async (parent, { userId }, { models }) => {
@@ -146,24 +111,91 @@ export default {
return await createItem(parent, { title, url: ensureProtocol(url) }, { me, models })
},
- createDiscussion: async (parent, { title, text }, { me, models }) => {
+ updateLink: async (parent, { id, title, url }, { me, models }) => {
+ if (!id) {
+ throw new UserInputError('link must have id', { argumentName: 'id' })
+ }
+
if (!title) {
throw new UserInputError('link must have title', { argumentName: 'title' })
}
+ if (!url) {
+ throw new UserInputError('link must have url', { argumentName: 'url' })
+ }
+
+ // update iff this item belongs to me
+ const item = await models.item.findUnique({ where: { id: Number(id) } })
+ if (Number(item.userId) !== Number(me.id)) {
+ throw new AuthenticationError('item does not belong to you')
+ }
+
+ if (Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
+ throw new UserInputError('item can no longer be editted')
+ }
+
+ return await updateItem(parent, { id, data: { title, url: ensureProtocol(url) } }, { me, models })
+ },
+ createDiscussion: async (parent, { title, text }, { me, models }) => {
+ if (!title) {
+ throw new UserInputError('discussion must have title', { argumentName: 'title' })
+ }
+
return await createItem(parent, { title, text }, { me, models })
},
+ updateDiscussion: async (parent, { id, title, text }, { me, models }) => {
+ if (!id) {
+ throw new UserInputError('discussion must have id', { argumentName: 'id' })
+ }
+
+ if (!title) {
+ throw new UserInputError('discussion must have title', { argumentName: 'title' })
+ }
+
+ // update iff this item belongs to me
+ const item = await models.item.findUnique({ where: { id: Number(id) } })
+ if (Number(item.userId) !== Number(me.id)) {
+ throw new AuthenticationError('item does not belong to you')
+ }
+
+ if (Date.now() > new Date(item.createdAt).getTime() + 10 * 60000) {
+ throw new UserInputError('item can no longer be editted')
+ }
+
+ return await updateItem(parent, { id, data: { title, text } }, { me, models })
+ },
createComment: async (parent, { text, parentId }, { me, models }) => {
if (!text) {
throw new UserInputError('comment must have text', { argumentName: 'text' })
}
if (!parentId) {
- throw new UserInputError('comment must have parent', { argumentName: 'text' })
+ throw new UserInputError('comment must have parent', { argumentName: 'parentId' })
}
return await createItem(parent, { text, parentId }, { me, models })
},
+ updateComment: async (parent, { id, text }, { me, models }) => {
+ if (!text) {
+ throw new UserInputError('comment must have text', { argumentName: 'text' })
+ }
+
+ if (!id) {
+ throw new UserInputError('comment must have id', { argumentName: 'id' })
+ }
+
+ // update iff this comment belongs to me
+ const comment = await models.item.findUnique({ where: { id: Number(id) } })
+ if (Number(comment.userId) !== Number(me.id)) {
+ throw new AuthenticationError('comment does not belong to you')
+ }
+
+ if (Date.now() > new Date(comment.createdAt).getTime() + 10 * 60000) {
+ throw new UserInputError('comment can no longer be editted')
+ }
+
+ return await updateItem(parent, { id, data: { text } }, { me, models })
+ },
vote: async (parent, { id, sats = 1 }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
@@ -251,6 +283,56 @@ export default {
}
}
+const namePattern = /\B@[\w_]+/gi
+
+const createMentions = async (item, models) => {
+ // if we miss a mention, in the rare circumstance there's some kind of
+ // failure, it's not a big deal so we don't do it transactionally
+ // ideally, we probably would
+ if (!item.text) {
+ return
+ }
+
+ 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 }
+ }
+ })
+
+ users.forEach(async user => {
+ const data = {
+ itemId: item.id,
+ userId: user.id
+ }
+
+ await models.mention.upsert({
+ where: {
+ itemId_userId: data
+ },
+ update: data,
+ create: data
+ })
+ })
+ }
+ } catch (e) {
+ console.log('mention failure', e)
+ }
+}
+
+const updateItem = async (parent, { id, data }, { me, models }) => {
+ const item = await models.item.update({
+ where: { id: Number(id) },
+ data
+ })
+
+ await createMentions(item, models)
+
+ return item
+}
+
const createItem = async (parent, { title, url, text, parentId }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
@@ -259,6 +341,9 @@ const createItem = async (parent, { title, url, text, parentId }, { me, models }
const [item] = await serialize(models, models.$queryRaw(
`${SELECT} FROM create_item($1, $2, $3, $4, $5) AS "Item"`,
title, url, text, Number(parentId), me.name))
+
+ await createMentions(item, models)
+
item.comments = []
return item
}
diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js
new file mode 100644
index 00000000..59b752fb
--- /dev/null
+++ b/api/resolvers/notifications.js
@@ -0,0 +1,107 @@
+import { AuthenticationError } from 'apollo-server-micro'
+import { decodeCursor, LIMIT, nextCursorEncoded } from './cursor'
+
+export default {
+ Query: {
+ notifications: async (parent, { cursor }, { me, models }) => {
+ const decodedCursor = decodeCursor(cursor)
+ if (!me) {
+ throw new AuthenticationError('you must be logged in')
+ }
+
+ /*
+ So that we can cursor over results, we union notifications together ...
+ this requires we have the same number of columns in all results
+
+ select "Item".id, NULL as earnedSats, "Item".created_at as created_at from
+ "Item" JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = 622 AND
+ "Item"."userId" <> 622 UNION ALL select "Item".id, "Vote".sats as earnedSats,
+ "Vote".created_at as created_at FROM "Item" LEFT JOIN "Vote" on
+ "Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622 AND "Vote".boost = false
+ WHERE "Item"."userId" = 622 ORDER BY created_at DESC;
+
+ Because we want to "collapse" time adjacent votes in the result
+
+ select vote.id, sum(vote."earnedSats") as "earnedSats", max(vote.voted_at)
+ as "createdAt" from (select "Item".*, "Vote".sats as "earnedSats",
+ "Vote".created_at as voted_at, ROW_NUMBER() OVER(ORDER BY "Vote".created_at) -
+ ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "Vote".created_at) as island
+ FROM "Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND
+ "Vote"."userId" <> 622 AND "Vote".boost = false WHERE "Item"."userId" = 622)
+ as vote group by vote.id, vote.island order by max(vote.voted_at) desc;
+
+ We can also "collapse" votes occuring within 1 hour intervals of each other
+ (I haven't yet combined with the above collapsing method .. but might be
+ overkill)
+
+ select "Item".id, sum("Vote".sats) as earnedSats, max("Vote".created_at)
+ as created_at, ROW_NUMBER() OVER(ORDER BY max("Vote".created_at)) - ROW_NUMBER()
+ OVER(PARTITION BY "Item".id ORDER BY max("Vote".created_at)) as island FROM
+ "Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622
+ AND "Vote".boost = false WHERE "Item"."userId" = 622 group by "Item".id,
+ date_trunc('hour', "Vote".created_at) order by created_at desc;
+ */
+
+ let notifications = await models.$queryRaw(`
+ SELECT ${ITEM_FIELDS}, "Item".created_at as "sortTime", NULL as "earnedSats",
+ false as mention
+ From "Item"
+ JOIN "Item" p ON "Item"."parentId" = p.id
+ WHERE p."userId" = $1
+ AND "Item"."userId" <> $1 AND "Item".created_at <= $2
+ UNION ALL
+ (SELECT ${ITEM_SUBQUERY_FIELDS}, max(subquery.voted_at) as "sortTime",
+ sum(subquery.sats) as "earnedSats", false as mention
+ FROM
+ (SELECT ${ITEM_FIELDS}, "Vote".created_at as voted_at, "Vote".sats,
+ ROW_NUMBER() OVER(ORDER BY "Vote".created_at) -
+ ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "Vote".created_at) as island
+ FROM "Vote"
+ JOIN "Item" on "Vote"."itemId" = "Item".id
+ WHERE "Vote"."userId" <> $1
+ AND "Vote".created_at <= $2
+ AND "Vote".boost = false
+ AND "Item"."userId" = $1) subquery
+ GROUP BY ${ITEM_SUBQUERY_FIELDS}, subquery.island ORDER BY max(subquery.voted_at) desc)
+ UNION ALL
+ (SELECT ${ITEM_FIELDS}, "Mention".created_at as "sortTime", NULL as "earnedSats",
+ true as mention
+ FROM "Mention"
+ JOIN "Item" on "Mention"."itemId" = "Item".id
+ JOIN "Item" p on "Item"."parentId" = p.id
+ WHERE "Mention"."userId" = $1
+ AND "Mention".created_at <= $2
+ AND "Item"."userId" <> $1
+ AND p."userId" <> $1)
+ ORDER BY "sortTime" DESC
+ OFFSET $3
+ LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
+
+ notifications = notifications.map(n => {
+ n.item = { ...n }
+ return n
+ })
+
+ const { checkedNotesAt } = await models.user.findUnique({ where: { id: me.id } })
+ await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
+
+ return {
+ lastChecked: checkedNotesAt,
+ cursor: notifications.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
+ notifications
+ }
+ }
+ },
+ Notification: {
+ __resolveType: async (notification, args, { models }) =>
+ notification.earnedSats ? 'Votification' : (notification.mention ? 'Mention' : 'Reply')
+ }
+}
+
+const ITEM_SUBQUERY_FIELDS =
+ `subquery.id, subquery."createdAt", subquery."updatedAt", subquery.title, subquery.text,
+ subquery.url, subquery."userId", subquery."parentId", subquery.path`
+
+const ITEM_FIELDS =
+ `"Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
+ "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path`
diff --git a/api/resolvers/serial.js b/api/resolvers/serial.js
index 115fca9b..da841435 100644
--- a/api/resolvers/serial.js
+++ b/api/resolvers/serial.js
@@ -18,10 +18,10 @@ async function serialize (models, call) {
bail(new Error('wallet balance transaction is not serializable'))
}
if (error.message.includes('SN_CONFIRMED_WITHDRAWL_EXISTS')) {
- bail(new Error('withdrawl invoice already confirmed (to withdrawl again create a new invoice)'))
+ bail(new Error('withdrawal invoice already confirmed (to withdraw again create a new invoice)'))
}
if (error.message.includes('SN_PENDING_WITHDRAWL_EXISTS')) {
- bail(new Error('withdrawl invoice exists and is pending'))
+ bail(new Error('withdrawal invoice exists and is pending'))
}
if (error.message.includes('40001')) {
throw new Error('wallet balance serialization failure - retry again')
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 9a198482..4a7369a7 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -25,12 +25,12 @@ export default {
const [{ sum }] = await models.$queryRaw(`
SELECT sum("Vote".sats)
- FROM "Item"
- LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id
- AND "Vote"."userId" <> $1
+ FROM "Vote"
+ JOIN "Item" on "Vote"."itemId" = "Item".id
+ WHERE "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false
- WHERE "Item"."userId" = $1`, user.id, user.checkedNotesAt)
+ AND "Item"."userId" = $1`, user.id, user.checkedNotesAt)
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
return sum || 0
@@ -64,9 +64,10 @@ export default {
stacked: async (user, args, { models }) => {
const [{ sum }] = await models.$queryRaw`
SELECT sum("Vote".sats)
- FROM "Item"
- LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> ${user.id} AND boost = false
- WHERE "Item"."userId" = ${user.id}`
+ FROM "Vote"
+ JOIN "Item" on "Vote"."itemId" = "Item".id
+ WHERE "Vote"."userId" <> ${user.id} AND boost = false
+ AND "Item"."userId" = ${user.id}`
return sum || 0
},
sats: async (user, args, { models }) => {
@@ -77,11 +78,11 @@ export default {
const votes = await models.$queryRaw(`
SELECT "Vote".id, "Vote".created_at
FROM "Vote"
- LEFT JOIN "Item" on "Vote"."itemId" = "Item".id
- AND "Vote"."userId" <> $1
+ JOIN "Item" on "Vote"."itemId" = "Item".id
+ WHERE "Vote"."userId" <> $1
AND ("Vote".created_at > $2 OR $2 IS NULL)
AND "Vote".boost = false
- WHERE "Item"."userId" = $1
+ AND "Item"."userId" = $1
LIMIT 1`, user.id, user.checkedNotesAt)
if (votes.length > 0) {
return true
@@ -91,10 +92,24 @@ export default {
const newReplies = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at
From "Item"
- JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = $1
+ JOIN "Item" p ON "Item"."parentId" = p.id
+ WHERE p."userId" = $1
AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
LIMIT 1`, user.id, user.checkedNotesAt)
- return !!newReplies.length
+ if (newReplies.length > 0) {
+ return true
+ }
+
+ // check if they have any mentions since checkedNotesAt
+ const newMentions = await models.$queryRaw(`
+ SELECT "Item".id, "Item".created_at
+ From "Mention"
+ JOIN "Item" ON "Mention"."itemId" = "Item".id
+ WHERE "Mention"."userId" = $1
+ AND ("Mention".created_at > $2 OR $2 IS NULL)
+ AND "Item"."userId" <> $1
+ LIMIT 1`, user.id, user.checkedNotesAt)
+ return newMentions.length > 0
}
}
}
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index 7d89e6fd..cf3e3d2f 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -39,7 +39,7 @@ export default {
})
if (wdrwl.user.id !== me.id) {
- throw new AuthenticationError('not ur withdrawl')
+ throw new AuthenticationError('not ur withdrawal')
}
return wdrwl
@@ -101,6 +101,11 @@ export default {
throw new UserInputError('could not decode invoice')
}
+ // TODO: test
+ if (!decoded.mtokens || Number(decoded.mtokens) <= 0) {
+ throw new UserInputError('you must specify amount')
+ }
+
const msatsFee = Number(maxFee) * 1000
// create withdrawl transactionally (id, bolt11, amount, fee)
@@ -118,8 +123,11 @@ export default {
})
// if it's confirmed, update confirmed returning extra fees to user
- sub.on('confirmed', async e => {
+ sub.once('confirmed', async e => {
console.log(e)
+
+ sub.removeAllListeners()
+
// mtokens also contains the fee
const fee = Number(e.fee_mtokens)
const paid = Number(e.mtokens) - fee
@@ -130,8 +138,11 @@ export default {
// if the payment fails, we need to
// 1. return the funds to the user
// 2. update the widthdrawl as failed
- sub.on('failed', async e => {
+ sub.once('failed', async e => {
console.log(e)
+
+ sub.removeAllListeners()
+
let status = 'UNKNOWN_FAILURE'
if (e.is_insufficient_balance) {
status = 'INSUFFICIENT_BALANCE'
diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js
index 025546b9..093272c7 100644
--- a/api/typeDefs/index.js
+++ b/api/typeDefs/index.js
@@ -5,6 +5,7 @@ import message from './message'
import item from './item'
import wallet from './wallet'
import lnurl from './lnurl'
+import notifications from './notifications'
const link = gql`
type Query {
@@ -20,4 +21,4 @@ const link = gql`
}
`
-export default [link, user, item, message, wallet, lnurl]
+export default [link, user, item, message, wallet, lnurl, notifications]
diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js
index c5d924cf..c01a8378 100644
--- a/api/typeDefs/item.js
+++ b/api/typeDefs/item.js
@@ -4,15 +4,17 @@ export default gql`
extend type Query {
moreItems(sort: String!, cursor: String, userId: ID): Items
moreFlatComments(cursor: String, userId: ID): Comments
- notifications: [Item!]!
item(id: ID!): Item
userComments(userId: ID!): [Item!]
}
extend type Mutation {
createLink(title: String!, url: String): Item!
+ updateLink(id: ID!, title: String!, url: String): Item!
createDiscussion(title: String!, text: String): Item!
+ updateDiscussion(id: ID!, title: String!, text: String): Item!
createComment(text: String!, parentId: ID!): Item!
+ updateComment(id: ID!, text: String!): Item!
vote(id: ID!, sats: Int): Int!
}
@@ -42,5 +44,6 @@ export default gql`
meSats: Int!
ncomments: Int!
comments: [Item!]!
+ path: String
}
`
diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js
new file mode 100644
index 00000000..3071db4e
--- /dev/null
+++ b/api/typeDefs/notifications.js
@@ -0,0 +1,32 @@
+import { gql } from 'apollo-server-micro'
+
+export default gql`
+ extend type Query {
+ notifications(cursor: String): Notifications
+ }
+
+ type Votification {
+ earnedSats: Int!
+ item: Item!
+ sortTime: String!
+ }
+
+ type Reply {
+ item: Item!
+ sortTime: String!
+ }
+
+ type Mention {
+ mention: Boolean!
+ item: Item!
+ sortTime: String!
+ }
+
+ union Notification = Reply | Votification | Mention
+
+ type Notifications {
+ lastChecked: String
+ cursor: String
+ notifications: [Notification!]!
+ }
+`
diff --git a/components/action-tooltip.js b/components/action-tooltip.js
index 5de6ae62..9421ee8e 100644
--- a/components/action-tooltip.js
+++ b/components/action-tooltip.js
@@ -1,6 +1,12 @@
+import { useFormikContext } from 'formik'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
-export default function ActionTooltip ({ children }) {
+export default function ActionTooltip ({ children, notForm }) {
+ // if we're in a form, we want to hide tooltip on submit
+ let formik
+ if (!notForm) {
+ formik = useFormikContext()
+ }
return (