Merge branch 'stackernews:master' into master

This commit is contained in:
kerooke 2022-12-29 11:38:53 -05:00 committed by GitHub
commit 335125f57b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1316 additions and 269 deletions

View File

@ -1,6 +1,6 @@
const PLACEHOLDERS_NUM = 616
function interval (when) {
export function interval (when) {
switch (when) {
case 'week':
return '1 week'
@ -15,7 +15,7 @@ function interval (when) {
}
}
function timeUnit (when) {
export function timeUnit (when) {
switch (when) {
case 'week':
case 'month':
@ -28,7 +28,7 @@ function timeUnit (when) {
}
}
function withClause (when) {
export function withClause (when) {
const ival = interval(when)
const unit = timeUnit(when)
@ -44,7 +44,7 @@ function withClause (when) {
}
// HACKY AF this is a performance enhancement that allows us to use the created_at indices on tables
function intervalClause (when, table, and) {
export function intervalClause (when, table, and) {
if (when === 'forever') {
return and ? '' : 'TRUE'
}
@ -58,7 +58,7 @@ export default {
return await models.$queryRaw(
`${withClause(when)}
SELECT time, json_build_array(
json_build_object('name', 'invited', 'value', count("inviteId")),
json_build_object('name', 'referrals', 'value', count("referrerId")),
json_build_object('name', 'organic', 'value', count(users.id) FILTER(WHERE id > ${PLACEHOLDERS_NUM}) - count("inviteId"))
) AS data
FROM times
@ -74,10 +74,18 @@ export default {
json_build_object('name', 'jobs', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'STREAM')),
json_build_object('name', 'boost', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'BOOST')),
json_build_object('name', 'fees', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'FEE')),
json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP'))
json_build_object('name', 'tips', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'TIP')),
json_build_object('name', 'donation', 'value', count(DISTINCT "userId") FILTER (WHERE act = 'DONATION'))
) AS data
FROM times
LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at)
LEFT JOIN
((SELECT "ItemAct".created_at, "userId", act::text as act
FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)})
UNION ALL
(SELECT created_at, "userId", 'DONATION' as act
FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`)
},
@ -98,14 +106,21 @@ export default {
return await models.$queryRaw(
`${withClause(when)}
SELECT time, json_build_array(
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM') THEN "ItemAct".msats ELSE 0 END)/1000),0)),
json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN "ItemAct".msats ELSE 0 END)/1000),0))
json_build_object('name', 'jobs', 'value', coalesce(floor(sum(CASE WHEN act = 'STREAM' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'boost', 'value', coalesce(floor(sum(CASE WHEN act = 'BOOST' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'fees', 'value', coalesce(floor(sum(CASE WHEN act NOT IN ('BOOST', 'TIP', 'STREAM', 'DONATION') THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'tips', 'value', coalesce(floor(sum(CASE WHEN act = 'TIP' THEN msats ELSE 0 END)/1000),0)),
json_build_object('name', 'donations', 'value', coalesce(floor(sum(CASE WHEN act = 'DONATION' THEN msats ELSE 0 END)/1000),0))
) AS data
FROM times
LEFT JOIN "ItemAct" ON ${intervalClause(when, 'ItemAct', true)} time = date_trunc('${timeUnit(when)}', created_at)
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN
((SELECT "ItemAct".created_at, msats, act::text as act
FROM "ItemAct"
WHERE ${intervalClause(when, 'ItemAct', false)})
UNION ALL
(SELECT created_at, sats * 1000 as msats, 'DONATION' as act
FROM "Donation"
WHERE ${intervalClause(when, 'Donation', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`)
},
@ -116,7 +131,8 @@ export default {
json_build_object('name', 'any', 'value', count(distinct user_id)),
json_build_object('name', 'posts', 'value', count(distinct user_id) FILTER (WHERE type = 'POST')),
json_build_object('name', 'comments', 'value', count(distinct user_id) FILTER (WHERE type = 'COMMENT')),
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN'))
json_build_object('name', 'rewards', 'value', count(distinct user_id) FILTER (WHERE type = 'EARN')),
json_build_object('name', 'referrals', 'value', count(distinct user_id) FILTER (WHERE type = 'REFERRAL'))
) AS data
FROM times
LEFT JOIN
@ -127,7 +143,11 @@ export default {
UNION ALL
(SELECT created_at, "userId" as user_id, 'EARN' as type
FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
WHERE ${intervalClause(when, 'Earn', false)})
UNION ALL
(SELECT created_at, "referrerId" as user_id, 'REFERRAL' as type
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`)
},
@ -137,18 +157,24 @@ export default {
SELECT time, json_build_array(
json_build_object('name', 'rewards', 'value', coalesce(floor(sum(airdrop)/1000),0)),
json_build_object('name', 'posts', 'value', coalesce(floor(sum(post)/1000),0)),
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0))
json_build_object('name', 'comments', 'value', coalesce(floor(sum(comment)/1000),0)),
json_build_object('name', 'referrals', 'value', coalesce(floor(sum(referral)/1000),0))
) AS data
FROM times
LEFT JOIN
((SELECT "ItemAct".created_at, 0 as airdrop,
CASE WHEN "Item"."parentId" IS NULL THEN 0 ELSE "ItemAct".msats END as comment,
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post
CASE WHEN "Item"."parentId" IS NULL THEN "ItemAct".msats ELSE 0 END as post,
0 as referral
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE ${intervalClause(when, 'ItemAct', true)} "ItemAct".act = 'TIP')
UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment
(SELECT created_at, 0 as airdrop, 0 as post, 0 as comment, msats as referral
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', false)})
UNION ALL
(SELECT created_at, msats as airdrop, 0 as post, 0 as comment, 0 as referral
FROM "Earn"
WHERE ${intervalClause(when, 'Earn', false)})) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time

View File

@ -9,7 +9,9 @@ import sub from './sub'
import upload from './upload'
import growth from './growth'
import search from './search'
import rewards from './rewards'
import referrals from './referrals'
import { GraphQLJSONObject } from 'graphql-type-json'
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
upload, growth, search, { JSONObject: GraphQLJSONObject }]
upload, growth, search, rewards, referrals, { JSONObject: GraphQLJSONObject }]

View File

@ -14,13 +14,13 @@ async function comments (me, models, id, sort) {
let orderBy
switch (sort) {
case 'top':
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".id DESC`
orderBy = `ORDER BY ${await orderByNumerator(me, models)} DESC, "Item".msats DESC, "Item".id DESC`
break
case 'recent':
orderBy = 'ORDER BY "Item".created_at DESC, "Item".id DESC'
orderBy = 'ORDER BY "Item".created_at DESC, "Item".msats DESC, "Item".id DESC'
break
default:
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".id DESC`
orderBy = `ORDER BY ${await orderByNumerator(me, models)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, "Item".id DESC`
break
}

View File

@ -160,6 +160,15 @@ export default {
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
queries.push(
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
'Referral' AS type
FROM users
WHERE "users"."referrerId" = $1
AND "inviteId" IS NULL
AND users.created_at <= $2
LIMIT ${LIMIT}+$3)`
)
}
if (meFull.noteEarning) {

View File

@ -0,0 +1,55 @@
import { AuthenticationError } from 'apollo-server-micro'
import { withClause, intervalClause, timeUnit } from './growth'
export default {
Query: {
referrals: async (parent, { when }, { models, me }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
const [{ totalSats }] = await models.$queryRaw(`
SELECT COALESCE(FLOOR(sum(msats) / 1000), 0) as "totalSats"
FROM "ReferralAct"
WHERE ${intervalClause(when, 'ReferralAct', true)}
"ReferralAct"."referrerId" = $1
`, Number(me.id))
const [{ totalReferrals }] = await models.$queryRaw(`
SELECT count(*) as "totalReferrals"
FROM users
WHERE ${intervalClause(when, 'users', true)}
"referrerId" = $1
`, Number(me.id))
const stats = await models.$queryRaw(
`${withClause(when)}
SELECT time, json_build_array(
json_build_object('name', 'referrals', 'value', count(*) FILTER (WHERE act = 'REFERREE')),
json_build_object('name', 'sats', 'value', FLOOR(COALESCE(sum(msats) FILTER (WHERE act IN ('BOOST', 'STREAM', 'FEE')), 0)))
) AS data
FROM times
LEFT JOIN
((SELECT "ReferralAct".created_at, "ReferralAct".msats / 1000.0 as msats, "ItemAct".act::text as act
FROM "ReferralAct"
JOIN "ItemAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE ${intervalClause(when, 'ReferralAct', true)}
"ReferralAct"."referrerId" = $1)
UNION ALL
(SELECT created_at, 0.0 as sats, 'REFERREE' as act
FROM users
WHERE ${intervalClause(when, 'users', true)}
"referrerId" = $1)) u ON time = date_trunc('${timeUnit(when)}', u.created_at)
GROUP BY time
ORDER BY time ASC`, Number(me.id))
console.log(totalSats)
return {
totalSats,
totalReferrals,
stats
}
}
}
}

49
api/resolvers/rewards.js Normal file
View File

@ -0,0 +1,49 @@
import { AuthenticationError } from 'apollo-server-micro'
import serialize from './serial'
export default {
Query: {
expectedRewards: async (parent, args, { models }) => {
// get the last reward time, then get all contributions to rewards since then
const lastReward = await models.earn.findFirst({
orderBy: {
createdAt: 'desc'
}
})
const [result] = await models.$queryRaw`
SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)),
json_build_object('name', 'fees', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type NOT IN ('BOOST', 'STREAM', 'DONATION'))), 0)),
json_build_object('name', 'boost', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'BOOST')), 0)),
json_build_object('name', 'jobs', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'STREAM')), 0))
) AS sources
FROM (
(SELECT ("ItemAct".msats - COALESCE("ReferralAct".msats, 0)) / 1000.0 as sats, act::text as type
FROM "ItemAct"
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE "ItemAct".created_at > ${lastReward.createdAt} AND "ItemAct".act <> 'TIP')
UNION ALL
(SELECT sats::FLOAT, 'DONATION' as type
FROM "Donation"
WHERE created_at > ${lastReward.createdAt})
) subquery`
return result
}
},
Mutation: {
donateToRewards: async (parent, { sats }, { me, models }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
await serialize(models,
models.$queryRaw(
'SELECT donate($1, $2)',
sats, Number(me.id)))
return sats
}
}
}

View File

@ -93,12 +93,20 @@ export default {
let users
if (sort === 'spent') {
users = await models.$queryRaw(`
SELECT users.*, floor(sum("ItemAct".msats)/1000) as spent
SELECT users.*, sum(sats_spent) as spent
FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct"
JOIN users on "ItemAct"."userId" = users.id
WHERE "ItemAct".created_at <= $1
AND NOT users."hideFromTopUsers"
${within('ItemAct', when)}
GROUP BY "userId")
UNION ALL
(SELECT "userId", sats as sats_spent
FROM "Donation"
WHERE created_at <= $1
${within('Donation', when)})) spending
JOIN users on spending."userId" = users.id
AND NOT users."hideFromTopUsers"
GROUP BY users.id, users.name
ORDER BY spent DESC NULLS LAST, users.created_at DESC
OFFSET $2
@ -127,6 +135,18 @@ export default {
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
} else if (sort === 'referrals') {
users = await models.$queryRaw(`
SELECT users.*, count(*) as referrals
FROM users
JOIN "users" referree on users.id = referree."referrerId"
WHERE referree.created_at <= $1
AND NOT users."hideFromTopUsers"
${within('referree', when)}
GROUP BY users.id
ORDER BY referrals DESC NULLS LAST, users.created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
} else {
users = await models.$queryRaw(`
SELECT u.id, u.name, u."photoId", floor(sum(amount)/1000) as stacked
@ -143,6 +163,12 @@ export default {
FROM "Earn"
JOIN users on users.id = "Earn"."userId"
WHERE "Earn".msats > 0 ${within('Earn', when)}
AND NOT users."hideFromTopUsers")
UNION ALL
(SELECT users.*, "ReferralAct".msats as amount
FROM "ReferralAct"
JOIN users on users.id = "ReferralAct"."referrerId"
WHERE "ReferralAct".msats > 0 ${within('ReferralAct', when)}
AND NOT users."hideFromTopUsers")) u
GROUP BY u.id, u.name, u.created_at, u."photoId"
ORDER BY stacked DESC NULLS LAST, created_at DESC
@ -263,6 +289,18 @@ export default {
if (newInvitees.length > 0) {
return true
}
const referral = await models.user.findFirst({
where: {
referrerId: me.id,
createdAt: {
gt: lastChecked
}
}
})
if (referral) {
return true
}
}
return false
@ -433,13 +471,18 @@ export default {
const [{ stacked }] = await models.$queryRaw(`
SELECT sum(amount) as stacked
FROM
((SELECT sum("ItemAct".msats) as amount
((SELECT coalesce(sum("ItemAct".msats),0) as amount
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE act <> 'BOOST' AND "ItemAct"."userId" <> $2 AND "Item"."userId" = $2
AND "ItemAct".created_at >= $1)
UNION ALL
(SELECT sum("Earn".msats) as amount
(SELECT coalesce(sum("ReferralAct".msats),0) as amount
FROM "ReferralAct"
WHERE "ReferralAct".msats > 0 AND "ReferralAct"."referrerId" = $2
AND "ReferralAct".created_at >= $1)
UNION ALL
(SELECT coalesce(sum("Earn".msats), 0) as amount
FROM "Earn"
WHERE "Earn".msats > 0 AND "Earn"."userId" = $2
AND "Earn".created_at >= $1)) u`, withinDate(when), Number(user.id))
@ -465,6 +508,16 @@ export default {
return (msats && msatsToSats(msats)) || 0
},
referrals: async (user, { when }, { models }) => {
return await models.user.count({
where: {
referrerId: user.id,
createdAt: {
gte: withinDate(when)
}
}
})
},
sats: async (user, args, { models, me }) => {
if (me?.id !== user.id) {
return 0

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats } from '../../lib/format'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
export async function getInvoice (parent, { id }, { me, models }) {
if (!me) {
@ -110,6 +110,12 @@ export default {
FROM "Earn"
WHERE "Earn"."userId" = $1 AND "Earn".created_at <= $2
GROUP BY "userId", created_at)`)
queries.push(
`(SELECT ('referral' || "ReferralAct".id) as id, "ReferralAct".id as "factId", NULL as bolt11,
created_at as "createdAt", msats,
0 as "msatsFee", NULL as status, 'referral' as type
FROM "ReferralAct"
WHERE "ReferralAct"."referrerId" = $1 AND "ReferralAct".created_at <= $2)`)
}
if (include.has('spent')) {
@ -122,6 +128,13 @@ export default {
WHERE "ItemAct"."userId" = $1
AND "ItemAct".created_at <= $2
GROUP BY "Item".id)`)
queries.push(
`(SELECT ('donation' || "Donation".id) as id, "Donation".id as "factId", NULL as bolt11,
created_at as "createdAt", sats * 1000 as msats,
0 as "msatsFee", NULL as status, 'donation' as type
FROM "Donation"
WHERE "userId" = $1
AND created_at <= $2)`)
}
if (queries.length === 0) {
@ -157,6 +170,9 @@ export default {
case 'spent':
f.msats *= -1
break
case 'donation':
f.msats *= -1
break
default:
break
}
@ -277,8 +293,8 @@ export default {
return item
},
sats: fact => msatsToSats(fact.msats),
satsFee: fact => msatsToSats(fact.msatsFee)
sats: fact => msatsToSatsDecimal(fact.msats),
satsFee: fact => msatsToSatsDecimal(fact.msatsFee)
}
}

View File

@ -10,6 +10,8 @@ import invite from './invite'
import sub from './sub'
import upload from './upload'
import growth from './growth'
import rewards from './rewards'
import referrals from './referrals'
const link = gql`
type Query {
@ -26,4 +28,4 @@ const link = gql`
`
export default [link, user, item, message, wallet, lnurl, notifications, invite,
sub, upload, growth]
sub, upload, growth, rewards, referrals]

View File

@ -50,8 +50,12 @@ export default gql`
sortTime: String!
}
type Referral {
sortTime: String!
}
union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid
| Invitification | Earn | JobChanged | InvoicePaid | Referral
type Notifications {
lastChecked: String

13
api/typeDefs/referrals.js Normal file
View File

@ -0,0 +1,13 @@
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
referrals(when: String): Referrals!
}
type Referrals {
totalSats: Int!
totalReferrals: Int!
stats: [TimeData!]!
}
`

16
api/typeDefs/rewards.js Normal file
View File

@ -0,0 +1,16 @@
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
expectedRewards: ExpectedRewards!
}
extend type Mutation {
donateToRewards(sats: Int!): Int!
}
type ExpectedRewards {
total: Int!
sources: [NameValue!]!
}
`

View File

@ -19,8 +19,8 @@ export default gql`
extend type Mutation {
setName(name: String!): Boolean
setSettings(tipDefault: Int!, fiatCurrency: String!, noteItemSats: Boolean!, noteEarning: Boolean!,
noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
noteInvites: Boolean!, noteJobIndicator: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!,
wildWestMode: Boolean!, greeterMode: Boolean!): User
setPhoto(photoId: ID!): Int!
@ -45,10 +45,12 @@ export default gql`
ncomments(when: String): Int!
stacked(when: String): Int!
spent(when: String): Int!
referrals(when: String): Int!
freePosts: Int!
freeComments: Int!
hasInvites: Boolean!
tipDefault: Int!
turboTipping: Boolean!
fiatCurrency: String!
bio: Item
bioId: Int

View File

@ -41,8 +41,8 @@ export default gql`
factId: ID!
bolt11: String
createdAt: String!
sats: Int!
satsFee: Int
sats: Float!
satsFee: Float
status: String
type: String!
description: String

View File

@ -18,6 +18,7 @@ import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg'
import { Badge } from 'react-bootstrap'
import { abbrNum } from '../lib/format'
import Share from './share'
function Parent ({ item, rootText }) {
const ParentFrag = () => (
@ -169,6 +170,7 @@ export default function Comment ({
localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && <Share item={item} />}
</div>
{edit
? (

View File

@ -151,6 +151,13 @@ export default function Footer ({ noLinks }) {
<DarkModeIcon onClick={() => darkMode.toggle()} className='fill-grey theme' />
<LnIcon onClick={toggleLightning} width={24} height={24} className='ml-2 fill-grey theme' />
</div>}
<div className='mb-0' style={{ fontWeight: 500 }}>
<Link href='/rewards' passHref>
<a className='nav-link p-0 d-inline-flex'>
rewards
</a>
</Link>
</div>
<div className='mb-0' style={{ fontWeight: 500 }}>
<OverlayTrigger trigger='click' placement='top' overlay={AnalyticsPopover} rootClose>
<div className='nav-link p-0 d-inline-flex' style={{ cursor: 'pointer' }}>

View File

@ -92,13 +92,8 @@ export default function Header ({ sub }) {
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<Link href='/invites' passHref>
<NavDropdown.Item eventKey='invites'>invites
{me && !me.hasInvites &&
<div className='p-1 d-inline-block bg-success ml-1'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
<Link href='/referrals/month' passHref>
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<div className='d-flex align-items-center'>

View File

@ -1,8 +1,9 @@
import { InputGroup, Modal } from 'react-bootstrap'
import { Button, InputGroup, Modal } from 'react-bootstrap'
import React, { useState, useCallback, useContext, useRef, useEffect } from 'react'
import * as Yup from 'yup'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '../svgs/bolt.svg'
export const ItemActContext = React.createContext({
item: null,
@ -38,6 +39,7 @@ export function ItemActModal () {
const { item, setItem } = useItemAct()
const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
useEffect(() => {
inputRef.current?.focus()
@ -73,10 +75,26 @@ export function ItemActModal () {
label='amount'
name='amount'
innerRef={inputRef}
overrideValue={oValue}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div>
{[1, 10, 100, 1000, 10000].map(num =>
<Button
size='sm'
className={`${num > 1 ? 'ml-2' : ''} mb-2`}
key={num}
onClick={() => { setOValue(num) }}
>
<UpBolt
className='mr-1'
width={14}
height={14}
/>{num}
</Button>)}
</div>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>tip</SubmitButton>
</div>

View File

@ -6,6 +6,7 @@ import styles from './item.module.css'
import Link from 'next/link'
import { timeSince } from '../lib/time'
import EmailIcon from '../svgs/mail-open-line.svg'
import Share from './share'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = Yup.string().email().isValidSync(item.url)
@ -73,7 +74,11 @@ export default function ItemJob ({ item, toc, rank, children }) {
{item.maxBid > 0 && item.status === 'ACTIVE' && <Badge className={`${styles.newComment} ml-1`}>PROMOTED</Badge>}
</div>
</div>
{toc && <Toc text={item.text} />}
{toc &&
<>
<Share item={item} />
<Toc text={item.text} />
</>}
</div>
{children && (
<div className={`${styles.children}`} style={{ marginLeft: 'calc(42px + .8rem)' }}>

View File

@ -14,6 +14,7 @@ import { newComments } from '../lib/new-comments'
import { useMe } from './me'
import DontLikeThis from './dont-link-this'
import Flag from '../svgs/flag-fill.svg'
import Share from './share'
import { abbrNum } from '../lib/format'
export function SearchTitle ({ title }) {
@ -141,7 +142,11 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) {
</div>
{showFwdUser && item.fwdUser && <FwdUser user={item.fwdUser} />}
</div>
{toc && <Toc text={item.text} />}
{toc &&
<>
<Share item={item} />
<Toc text={item.text} />
</>}
</div>
{children && (
<div className={styles.children}>

View File

@ -1,10 +1,10 @@
import Layout from './layout'
import styles from './layout-center.module.css'
export default function LayoutCenter ({ children, ...props }) {
export default function LayoutCenter ({ children, footerLinks, ...props }) {
return (
<div className={styles.page}>
<Layout noContain noFooterLinks {...props}>
<Layout noContain noFooterLinks={!footerLinks} {...props}>
<div className={styles.content}>
{children}
</div>

View File

@ -20,7 +20,7 @@ function Notification ({ n }) {
<div
className='clickToContext'
onClick={e => {
if (n.__typename === 'Earn') {
if (n.__typename === 'Earn' || n.__typename === 'Referral') {
return
}
@ -88,6 +88,15 @@ function Notification ({ n }) {
</div>
</div>
)
: n.__typename === 'Referral'
? (
<>
<small className='font-weight-bold text-secondary ml-2'>
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>
</>
)
: n.__typename === 'InvoicePaid'
? (
<div className='font-weight-bold text-info ml-2 py-1'>

46
components/share.js Normal file
View File

@ -0,0 +1,46 @@
import { Dropdown } from 'react-bootstrap'
import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy'
import { useMe } from './me'
export default function Share ({ item }) {
const me = useMe()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return typeof window !== 'undefined' && navigator?.share
? (
<div className='ml-auto pointer d-flex align-items-center'>
<ShareIcon
className='mx-2 fill-grey theme'
onClick={() => {
if (navigator.share) {
navigator.share({
title: item.title || '',
text: '',
url
}).then(() => console.log('Successful share'))
.catch((error) => console.log('Error sharing', error))
} else {
console.log('no navigator.share')
}
}}
/>
</div>)
: (
<Dropdown alignRight className='ml-auto pointer d-flex align-items-center' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<ShareIcon className='mx-2 fill-grey theme' />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
className='text-center'
onClick={async () => {
copy(url)
}}
>
copy link
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>)
}

View File

@ -41,7 +41,7 @@ export default function TopHeader ({ cat }) {
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
name='sort'
size='sm'
items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts'] : ['votes', 'comments', 'sats']}
items={cat === 'users' ? ['stacked', 'spent', 'comments', 'posts', 'referrals'] : ['votes', 'comments', 'sats']}
/>
for
<Select

View File

@ -152,11 +152,19 @@ export default function UpVote ({ item, className }) {
}
)
const overlayText = () => {
if (me?.tipDefault) {
return `${me.tipDefault} sat${me.tipDefault > 1 ? 's' : ''}`
// what should our next tip be?
let sats = me?.tipDefault || 1
if (me?.turboTipping && item?.meSats) {
let raiseTip = sats
while (item?.meSats >= raiseTip) {
raiseTip *= 10
}
return '1 sat'
sats = raiseTip - item.meSats
}
const overlayText = () => {
return `${sats} sat${sats > 1 ? 's' : ''}`
}
const color = getColor(item?.meSats)
@ -196,11 +204,11 @@ export default function UpVote ({ item, className }) {
try {
await act({
variables: { id: item.id, sats: me.tipDefault || 1 },
variables: { id: item.id, sats },
optimisticResponse: {
act: {
id: `Item:${item.id}`,
sats: me.tipDefault || 1,
sats,
vote: 0
}
}

View File

@ -38,6 +38,7 @@ export default function UserList ({ users }) {
{abbrNum(user.ncomments)} comments
</a>
</Link>
{user.referrals > 0 && <span> \ {abbrNum(user.referrals)} referrals</span>}
</div>
</div>
</div>

161
components/when-charts.js Normal file
View File

@ -0,0 +1,161 @@
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area, ComposedChart, Bar } from 'recharts'
import { abbrNum } from '../lib/format'
import { useRouter } from 'next/router'
const dateFormatter = when => {
return timeStr => {
const date = new Date(timeStr)
switch (when) {
case 'week':
case 'month':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
case 'year':
case 'forever':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
default:
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
}
}
}
function xAxisName (when) {
switch (when) {
case 'week':
case 'month':
return 'days'
case 'year':
case 'forever':
return 'months'
default:
return 'hours'
}
}
const transformData = data => {
return data.map(entry => {
const obj = { time: entry.time }
entry.data.forEach(entry1 => {
obj[entry1.name] = entry1.value
})
return obj
})
}
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--theme-grey)',
'var(--danger)'
]
export function WhenAreaChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</AreaChart>
</ResponsiveContainer>
)
}
export function WhenLineChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<LineChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
</LineChart>
</ResponsiveContainer>
)
}
export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<ComposedChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{barNames?.map((v, i) =>
<Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--info)' fill='var(--info)' />)}
{areaNames?.map((v, i) =>
<Area yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
{lineNames?.map((v, i) =>
<Line yAxisId='left' key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</ComposedChart>
</ResponsiveContainer>
)
}

View File

@ -37,6 +37,9 @@ export const NOTIFICATIONS = gql`
tips
}
}
... on Referral {
sortTime
}
... on Reply {
sortTime
item {

View File

@ -12,6 +12,7 @@ export const ME = gql`
freePosts
freeComments
tipDefault
turboTipping
fiatCurrency
bioId
upvotePopover
@ -34,6 +35,7 @@ export const ME = gql`
export const SETTINGS_FIELDS = gql`
fragment SettingsFields on User {
tipDefault
turboTipping
fiatCurrency
noteItemSats
noteEarning
@ -65,12 +67,12 @@ ${SETTINGS_FIELDS}
export const SET_SETTINGS =
gql`
${SETTINGS_FIELDS}
mutation setSettings($tipDefault: Int!, $fiatCurrency: String!, $noteItemSats: Boolean!, $noteEarning: Boolean!,
$noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: String!, $noteItemSats: Boolean!,
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!) {
setSettings(tipDefault: $tipDefault, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats,
noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers,
wildWestMode: $wildWestMode, greeterMode: $greeterMode) {
@ -133,6 +135,7 @@ export const TOP_USERS = gql`
spent(when: $when)
ncomments(when: $when)
nitems(when: $when)
referrals(when: $when)
}
cursor
}

View File

@ -1,4 +1,4 @@
export const NOFOLLOW_LIMIT = 1000
export const NOFOLLOW_LIMIT = 100
export const BOOST_MIN = 5000
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
export const IMAGE_PIXELS_MAX = 35000000

View File

@ -16,3 +16,10 @@ export const msatsToSats = msats => {
}
return Number(BigInt(msats) / 1000n)
}
export const msatsToSatsDecimal = msats => {
if (msats === null || msats === undefined) {
return null
}
return fixedDecimal(msats / 1000.0, 3)
}

18
middleware.js Normal file
View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
export function middleware (request) {
const regex = /(\/.*)?\/r\/([\w_]+)/
const m = regex.exec(request.nextUrl.pathname)
const url = new URL(m[1] || '/', request.url)
url.search = request.nextUrl.search
url.hash = request.nextUrl.hash
const resp = NextResponse.redirect(url)
resp.cookies.set('sn_referrer', m[2])
return resp
}
export const config = {
matcher: ['/(.*/|)r/([\\w_]+)([?#]?.*)']
}

View File

@ -5,9 +5,7 @@ import prisma from '../../../api/models'
import nodemailer from 'nodemailer'
import { getSession } from 'next-auth/client'
export default (req, res) => NextAuth(req, res, options)
const options = {
export default (req, res) => NextAuth(req, res, {
callbacks: {
/**
* @param {object} token Decrypted JSON Web Token
@ -26,8 +24,17 @@ const options = {
token.user = { id: Number(user.id) }
}
if (isNewUser) {
// if referrer exists, set on user
if (req.cookies.sn_referrer && user?.id) {
const referrer = await prisma.user.findUnique({ where: { name: req.cookies.sn_referrer } })
if (referrer) {
await prisma.user.update({ where: { id: user.id }, data: { referrerId: referrer.id } })
}
}
// sign them up for the newsletter
if (isNewUser && profile.email) {
if (profile.email) {
fetch(process.env.LIST_MONK_URL + '/api/subscribers', {
method: 'POST',
headers: {
@ -43,6 +50,7 @@ const options = {
})
}).then(async r => console.log(await r.json())).catch(console.log)
}
}
return token
},
@ -130,7 +138,7 @@ const options = {
pages: {
signIn: '/login'
}
}
})
function sendVerificationRequest ({
identifier: email,

View File

@ -120,7 +120,7 @@ export default function Invites () {
<h2 className='mt-3 mb-0'>
invite links
</h2>
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust somewhat, e.g. group chats or DMs</small>
<small className='d-block text-muted font-weight-bold mx-5'>send these to people you trust, e.g. group chats or DMs</small>
</div>
<InviteForm />
{active.length > 0 && <InviteList name='active' invites={active} />}

75
pages/referrals/[when].js Normal file
View File

@ -0,0 +1,75 @@
import { gql } from 'apollo-server-micro'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { getGetServerSideProps } from '../../api/ssrApollo'
import { CopyInput, Form, Select } from '../../components/form'
import LayoutCenter from '../../components/layout-center'
import { useMe } from '../../components/me'
import { WhenComposedChart } from '../../components/when-charts'
export const getServerSideProps = getGetServerSideProps(
gql`
query Referrals($when: String!)
{
referrals(when: $when) {
totalSats
totalReferrals
stats {
time
data {
name
value
}
}
}
}`)
export default function Referrals ({ data: { referrals: { totalSats, totalReferrals, stats } } }) {
const router = useRouter()
const me = useMe()
return (
<LayoutCenter footerLinks>
<Form
initial={{
when: router.query.when
}}
>
<h4 className='font-weight-bold text-muted text-center pt-5 pb-3 d-flex align-items-center justify-content-center'>
{totalReferrals} referrals & {totalSats} sats in the last
<Select
groupClassName='mb-0 ml-2'
className='w-auto'
name='when'
size='sm'
items={['day', 'week', 'month', 'year', 'forever']}
onChange={(formik, e) => router.push(`/referrals/${e.target.value}`)}
/>
</h4>
</Form>
<WhenComposedChart data={stats} lineNames={['sats']} barNames={['referrals']} />
<div
className='text-small pt-5 px-3 d-flex w-100 align-items-center'
>
<div className='nav-item text-muted pr-2' style={{ 'white-space': 'nowrap' }}>referral link:</div>
<CopyInput
size='sm'
groupClassName='mb-0 w-100'
readOnly
noForm
placeholder={`https://stacker.news/r/${me.name}`}
/>
</div>
<ul className='py-3 text-muted'>
<li>{`appending /r/${me.name} to any SN link makes it a ref link`}
<ul>
<li>e.g. https://stacker.news/items/1/r/{me.name}</li>
</ul>
</li>
<li>earn 21% of boost and job fees spent by referred stackers</li>
<li>earn 2.1% of all tips received by referred stackers</li>
<li><Link href='/invites' passHref><a>invite links</a></Link> are also implicitly referral links</li>
</ul>
</LayoutCenter>
)
}

142
pages/rewards.js Normal file
View File

@ -0,0 +1,142 @@
import { gql } from 'apollo-server-micro'
import { useEffect, useRef, useState } from 'react'
import { Button, InputGroup, Modal } from 'react-bootstrap'
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import { getGetServerSideProps } from '../api/ssrApollo'
import { Form, Input, SubmitButton } from '../components/form'
import LayoutCenter from '../components/layout-center'
import * as Yup from 'yup'
import { useMutation, useQuery } from '@apollo/client'
import Link from 'next/link'
const REWARDS = gql`
{
expectedRewards {
total
sources {
name
value
}
}
}
`
export const getServerSideProps = getGetServerSideProps(REWARDS)
export default function Rewards ({ data: { expectedRewards: { total, sources } } }) {
const { data } = useQuery(REWARDS, { pollInterval: 1000 })
if (data) {
({ expectedRewards: { total, sources } } = data)
}
return (
<LayoutCenter footerLinks>
<h4 className='font-weight-bold text-muted text-center'>
<div>{total} sats to be rewarded today</div>
<Link href='/faq#how-do-i-earn-sats-on-stacker-news' passHref>
<a className='text-reset'><small><small><small>learn about rewards</small></small></small></a>
</Link>
</h4>
<div className='my-3 w-100'>
<GrowthPieChart data={sources} />
</div>
<DonateButton />
</LayoutCenter>
)
}
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--grey)'
]
function GrowthPieChart ({ data }) {
return (
<ResponsiveContainer width='100%' height={250} minWidth={200}>
<PieChart margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<Pie
dataKey='value'
isAnimationActive={false}
data={data}
cx='50%'
cy='50%'
outerRadius={80}
fill='var(--secondary)'
label
>
{
data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))
}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
export const DonateSchema = Yup.object({
amount: Yup.number().typeError('must be a number').required('required')
.positive('must be positive').integer('must be whole')
})
export function DonateButton () {
const [show, setShow] = useState(false)
const inputRef = useRef(null)
const [donateToRewards] = useMutation(
gql`
mutation donateToRewards($sats: Int!) {
donateToRewards(sats: $sats)
}`)
useEffect(() => {
inputRef.current?.focus()
}, [show])
return (
<>
<Button onClick={() => setShow(true)}>DONATE TO REWARDS</Button>
<Modal
show={show}
onHide={() => {
setShow(false)
}}
>
<div className='modal-close' onClick={() => setShow(false)}>X</div>
<Modal.Body>
<Form
initial={{
amount: 1000
}}
schema={DonateSchema}
onSubmit={async ({ amount }) => {
await donateToRewards({
variables: {
sats: Number(amount)
}
})
setShow(false)
}}
>
<Input
label='amount'
name='amount'
innerRef={inputRef}
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<div className='d-flex'>
<SubmitButton variant='success' className='ml-auto mt-1 px-4' value='TIP'>donate</SubmitButton>
</div>
</Form>
</Modal.Body>
</Modal>
</>
)
}

View File

@ -15,7 +15,6 @@ import { useRouter } from 'next/router'
import Item from '../components/item'
import Comment from '../components/comment'
import React from 'react'
import Info from '../components/info'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
@ -98,6 +97,25 @@ function Detail ({ fact }) {
</>
)
}
if (fact.type === 'donation') {
return (
<>
<div className={satusClass(fact.status)}>
You made a donation to <Link href='/rewards' passHref><a>daily rewards</a></Link>!
</div>
</>
)
}
if (fact.type === 'referral') {
return (
<>
<div className={satusClass(fact.status)}>
You stacked sats from <Link href='/referrals/month' passHref><a>a referral</a></Link>!
</div>
</>
)
}
if (!fact.item) {
return (
<>
@ -145,6 +163,8 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
case 'invoice':
return `/${fact.type}s/${fact.factId}`
case 'earn':
case 'donation':
case 'referral':
return
default:
return `/items/${fact.factId}`
@ -201,11 +221,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<th className={styles.type}>type</th>
<th>detail</th>
<th className={styles.sats}>
<div>sats
<Info>
<div className='font-weight-bold'>Sats are rounded down from millisats to the nearest sat, so the actual amount might be slightly larger.</div>
</Info>
</div>
sats
</th>
</tr>
</thead>
@ -220,7 +236,7 @@ export default function Satistics ({ data: { me, walletHistory: { facts, cursor
<td className={styles.description}>
<Detail fact={f} />
</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{Math.floor(f.sats)}</td>
<td className={`${styles.sats} ${satusClass(f.status)}`}>{f.sats}</td>
</tr>
</Wrapper>
)

View File

@ -14,6 +14,7 @@ import { useRouter } from 'next/router'
import Info from '../components/info'
import { CURRENCY_SYMBOLS } from '../components/price'
import Link from 'next/link'
import AccordianItem from '../components/accordian-item'
export const getServerSideProps = getGetServerSideProps(SETTINGS)
@ -59,6 +60,7 @@ export default function Settings ({ data: { settings } }) {
<Form
initial={{
tipDefault: settings?.tipDefault || 21,
turboTipping: settings?.turboTipping,
fiatCurrency: settings?.fiatCurrency || 'USD',
noteItemSats: settings?.noteItemSats,
noteEarning: settings?.noteEarning,
@ -82,10 +84,44 @@ export default function Settings ({ data: { settings } }) {
<Input
label='tip default'
name='tipDefault'
groupClassName='mb-0'
required
autoFocus
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to tip custom amounts</small>}
/>
<div className='mb-2'>
<AccordianItem
show={settings?.turboTipping}
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
body={<Checkbox
name='turboTipping'
label={
<div className='d-flex align-items-center'>turbo tipping
<Info>
<ul className='font-weight-bold'>
<li>Makes every additional bolt click raise your total tip to another 10x multiple of your default tip</li>
<li>e.g. if your tip default is 10 sats
<ul>
<li>1st click: 10 sats total tipped</li>
<li>2nd click: 100 sats total tipped</li>
<li>3rd click: 1000 sats total tipped</li>
<li>4th click: 10000 sats total tipped</li>
<li>and so on ...</li>
</ul>
</li>
<li>You can still custom tip via long press
<ul>
<li>the next bolt click rounds up to the next greatest 10x multiple of your default</li>
</ul>
</li>
</ul>
</Info>
</div>
}
/>}
/>
</div>
<Select
label='fiat currency'
name='fiatCurrency'
@ -110,7 +146,7 @@ export default function Settings ({ data: { settings } }) {
groupClassName='mb-0'
/>
<Checkbox
label='my invite links are redeemed'
label='someone joins using my invite or referral links'
name='noteInvites'
groupClassName='mb-0'
/>

View File

@ -1,11 +1,9 @@
import { gql } from '@apollo/client'
import { getGetServerSideProps } from '../../api/ssrApollo'
import Layout from '../../components/layout'
import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts'
import { Col, Row } from 'react-bootstrap'
import { abbrNum } from '../../lib/format'
import { UsageHeader } from '../../components/usage-header'
import { useRouter } from 'next/router'
import { WhenLineChart, WhenAreaChart } from '../../components/when-charts'
export const getServerSideProps = getGetServerSideProps(
gql`
@ -55,46 +53,6 @@ export const getServerSideProps = getGetServerSideProps(
}
}`)
// todo: this needs to accomodate hours, days, months now
const dateFormatter = when => {
return timeStr => {
const date = new Date(timeStr)
switch (when) {
case 'week':
case 'month':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${date.getUTCDate()}`
case 'year':
case 'forever':
return `${('0' + (date.getUTCMonth() % 12 + 1)).slice(-2)}/${String(date.getUTCFullYear()).slice(-2)}`
default:
return `${date.getHours() % 12 || 12}${date.getHours() >= 12 ? 'pm' : 'am'}`
}
}
}
function xAxisName (when) {
switch (when) {
case 'week':
case 'month':
return 'days'
case 'year':
case 'forever':
return 'months'
default:
return 'hours'
}
}
const transformData = data => {
return data.map(entry => {
const obj = { time: entry.time }
entry.data.forEach(entry1 => {
obj[entry1.name] = entry1.value
})
return obj
})
}
export default function Growth ({
data: { registrationGrowth, itemGrowth, spendingGrowth, spenderGrowth, stackingGrowth, stackerGrowth }
}) {
@ -104,111 +62,33 @@ export default function Growth ({
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stackers</div>
<GrowthLineChart data={stackerGrowth} />
<WhenLineChart data={stackerGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>stacking</div>
<GrowthAreaChart data={stackingGrowth} />
<WhenAreaChart data={stackingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spenders</div>
<GrowthLineChart data={spenderGrowth} />
<WhenLineChart data={spenderGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>spending</div>
<GrowthAreaChart data={spendingGrowth} />
<WhenAreaChart data={spendingGrowth} />
</Col>
</Row>
<Row>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>registrations</div>
<GrowthAreaChart data={registrationGrowth} />
<WhenAreaChart data={registrationGrowth} />
</Col>
<Col className='mt-3'>
<div className='text-center text-muted font-weight-bold'>items</div>
<GrowthAreaChart data={itemGrowth} />
<WhenAreaChart data={itemGrowth} />
</Col>
</Row>
</Layout>
)
}
const COLORS = [
'var(--secondary)',
'var(--info)',
'var(--success)',
'var(--boost)',
'var(--theme-grey)'
]
function GrowthAreaChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<AreaChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
</AreaChart>
</ResponsiveContainer>
)
}
function GrowthLineChart ({ data }) {
const router = useRouter()
if (!data || data.length === 0) {
return null
}
// transform data into expected shape
data = transformData(data)
// need to grab when
const when = router.query.when
return (
<ResponsiveContainer width='100%' height={300} minWidth={300}>
<LineChart
data={data}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 0
}}
>
<XAxis
dataKey='time' tickFormatter={dateFormatter(when)} name={xAxisName(when)}
tick={{ fill: 'var(--theme-grey)' }}
/>
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} />
<Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
</LineChart>
</ResponsiveContainer>
)
}

View File

@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "Donation" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sats" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Donation" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION donate(sats INTEGER, user_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats / 1000
INTO user_sats
FROM users WHERE id = user_id;
IF sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - (sats * 1000) WHERE id = user_id;
INSERT INTO "Donate" (sats, "userId", created_at, updated_at)
VALUES (sats, user_id, now_utc(), now_utc());
RETURN sats;
END;
$$;

View File

@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION donate(sats INTEGER, user_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats / 1000
INTO user_sats
FROM users WHERE id = user_id;
IF sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - (sats * 1000) WHERE id = user_id;
INSERT INTO "Donate" (sats, "userId", created_at, updated_at)
VALUES (sats, user_id, now_utc(), now_utc());
RETURN sats;
END;
$$;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "turboTipping" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION donate(sats INTEGER, user_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_sats INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats / 1000
INTO user_sats
FROM users WHERE id = user_id;
IF sats > user_sats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
UPDATE users SET msats = msats - (sats * 1000) WHERE id = user_id;
INSERT INTO "Donation" (sats, "userId", created_at, updated_at)
VALUES (sats, user_id, now_utc(), now_utc());
RETURN sats;
END;
$$;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "referrerId" INTEGER;
-- AddForeignKey
ALTER TABLE "users" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ReferralAct" (
"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,
"itemActId" INTEGER NOT NULL,
"msats" BIGINT NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReferralAct" ADD FOREIGN KEY ("itemActId") REFERENCES "ItemAct"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,202 @@
CREATE OR REPLACE FUNCTION referral_act(referrer_id INTEGER, item_act_id INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
act_msats BIGINT;
referral_act "ItemActType";
referral_msats BIGINT;
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT msats, act INTO act_msats, referral_act FROM "ItemAct" WHERE id = item_act_id;
IF referral_act IN ('FEE', 'BOOST', 'STREAM') THEN
referral_msats := CEIL(act_msats * .21);
INSERT INTO "ReferralAct" ("referrerId", "itemActId", msats, created_at, updated_at)
VALUES(referrer_id, item_act_id, referral_msats, now_utc(), now_utc());
UPDATE users
SET msats = msats + referral_msats, "stackedMsats" = "stackedMsats" + referral_msats
WHERE id = referrer_id;
END IF;
RETURN 0;
END;
$$;
-- add referral act on item_act
CREATE OR REPLACE FUNCTION item_act(item_id INTEGER, user_id INTEGER, act "ItemActType", act_sats INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
act_msats BIGINT;
fee_msats BIGINT;
item_act_id INTEGER;
referrer_id INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
act_msats := act_sats * 1000;
SELECT msats, "referrerId" INTO user_msats, referrer_id FROM users WHERE id = user_id;
IF act_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- deduct msats from actor
UPDATE users SET msats = msats - act_msats WHERE id = user_id;
IF act = 'VOTE' THEN
RAISE EXCEPTION 'SN_UNSUPPORTED';
END IF;
IF act = 'TIP' THEN
-- call to influence weightedVotes ... we need to do this before we record the acts because
-- the priors acts are taken into account
PERFORM weighted_votes_after_tip(item_id, user_id, act_sats);
-- take 10% and insert as FEE
fee_msats := CEIL(act_msats * 0.1);
act_msats := act_msats - fee_msats;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (fee_msats, item_id, user_id, 'FEE', now_utc(), now_utc())
RETURNING id INTO item_act_id;
-- add sats to actee's balance and stacked count
UPDATE users
SET msats = msats + act_msats, "stackedMsats" = "stackedMsats" + act_msats
WHERE id = (SELECT COALESCE("fwdUserId", "userId") FROM "Item" WHERE id = item_id)
RETURNING "referrerId" INTO referrer_id;
-- leave the rest as a tip
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, 'TIP', now_utc(), now_utc());
-- call to denormalize sats and commentSats
PERFORM sats_after_tip(item_id, user_id, act_msats + fee_msats);
ELSE -- BOOST, POLL, DONT_LIKE_THIS, STREAM
-- call to influence if DONT_LIKE_THIS weightedDownVotes
IF act = 'DONT_LIKE_THIS' THEN
-- make sure they haven't done this before
IF EXISTS (SELECT 1 FROM "ItemAct" WHERE "itemId" = item_id AND "userId" = user_id AND "ItemAct".act = 'DONT_LIKE_THIS') THEN
RAISE EXCEPTION 'SN_DUPLICATE';
END IF;
PERFORM weighted_downvotes_after_act(item_id, user_id, act_sats);
END IF;
INSERT INTO "ItemAct" (msats, "itemId", "userId", act, created_at, updated_at)
VALUES (act_msats, item_id, user_id, act, now_utc(), now_utc())
RETURNING id INTO item_act_id;
END IF;
-- they have a referrer and the referrer isn't the one tipping them
IF referrer_id IS NOT NULL AND user_id <> referrer_id THEN
PERFORM referral_act(referrer_id, item_act_id);
END IF;
RETURN 0;
END;
$$;
CREATE OR REPLACE FUNCTION run_auction(item_id INTEGER) RETURNS void AS $$
DECLARE
bid_sats INTEGER;
user_msats BIGINT;
user_id INTEGER;
item_status "Status";
status_updated_at timestamp(3);
BEGIN
PERFORM ASSERT_SERIALIZED();
-- extract data we need
SELECT "maxBid", "userId", status, "statusUpdatedAt" INTO bid_sats, user_id, item_status, status_updated_at FROM "Item" WHERE id = item_id;
SELECT msats INTO user_msats FROM users WHERE id = user_id;
-- 0 bid items expire after 30 days unless updated
IF bid_sats = 0 THEN
IF item_status <> 'STOPPED' THEN
IF status_updated_at < now_utc() - INTERVAL '30 days' THEN
UPDATE "Item" SET status = 'STOPPED', "statusUpdatedAt" = now_utc() WHERE id = item_id;
ELSEIF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE' WHERE id = item_id;
END IF;
END IF;
RETURN;
END IF;
-- check if user wallet has enough sats
IF bid_sats * 1000 > user_msats THEN
-- if not, set status = NOSATS and statusUpdatedAt to now_utc if not already set
IF item_status <> 'NOSATS' THEN
UPDATE "Item" SET status = 'NOSATS', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
ELSE
PERFORM item_act(item_id, user_id, 'STREAM', bid_sats);
-- update item status = ACTIVE and statusUpdatedAt = now_utc if NOSATS
IF item_status = 'NOSATS' THEN
UPDATE "Item" SET status = 'ACTIVE', "statusUpdatedAt" = now_utc() WHERE id = item_id;
END IF;
END IF;
END;
$$ LANGUAGE plpgsql;
-- retro actively, turn all invites into referrals
UPDATE users
SET "referrerId" = subquery.inviter
FROM (
SELECT invitees.id AS invitee, inviters.id AS inviter
FROM users invitees
JOIN "Invite" ON invitees."inviteId" = "Invite".id
JOIN users inviters ON inviters.id = "Invite"."userId") subquery
WHERE id = subquery.invitee;
-- make inviters referrers too
CREATE OR REPLACE FUNCTION invite_drain(user_id INTEGER, invite_id TEXT)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
inviter_id INTEGER;
inviter_sats INTEGER;
gift INTEGER;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- check user was created in last hour
-- check user did not already redeem an invite
PERFORM FROM users
WHERE id = user_id AND users.created_at >= NOW() AT TIME ZONE 'UTC' - INTERVAL '1 HOUR'
AND users."inviteId" IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_INELIGIBLE';
END IF;
-- check that invite has not reached limit
-- check that invite is not revoked
SELECT "Invite"."userId", "Invite".gift INTO inviter_id, gift FROM "Invite"
LEFT JOIN users ON users."inviteId" = invite_id
WHERE "Invite".id = invite_id AND NOT "Invite".revoked
GROUP BY "Invite".id
HAVING COUNT(DISTINCT users.id) < "Invite".limit OR "Invite".limit IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- check that inviter has sufficient balance
SELECT (msats / 1000) INTO inviter_sats
FROM users WHERE id = inviter_id;
IF inviter_sats < gift THEN
RAISE EXCEPTION 'SN_REVOKED_OR_EXHAUSTED';
END IF;
-- subtract amount from inviter
UPDATE users SET msats = msats - (1000 * gift) WHERE id = inviter_id;
-- add amount to invitee
UPDATE users SET msats = msats + (1000 * gift), "inviteId" = invite_id, "referrerId" = inviter_id WHERE id = user_id;
RETURN 0;
END;
$$;

View File

@ -35,7 +35,6 @@ model User {
freeComments Int @default(5)
freePosts Int @default(2)
checkedNotesAt DateTime?
tipDefault Int @default(10)
fiatCurrency String @default("USD")
pubkey String? @unique
trust Float @default(0)
@ -48,6 +47,15 @@ model User {
upvotePopover Boolean @default(false)
tipPopover Boolean @default(false)
// referrals
referrer User? @relation("referrals", fields: [referrerId], references: [id])
referrerId Int?
referrees User[] @relation("referrals")
// tip settings
tipDefault Int @default(10)
turboTipping Boolean @default(false)
// notification settings
noteItemSats Boolean @default(true)
noteEarning Boolean @default(true)
@ -68,12 +76,23 @@ model User {
Earn Earn[]
Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[]
Donation Donation[]
ReferralAct ReferralAct[]
@@index([createdAt])
@@index([inviteId])
@@map(name: "users")
}
model Donation {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
sats Int
userId Int
user User @relation(fields: [userId], references: [id])
}
model Upload {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
@ -305,6 +324,17 @@ model Pin {
Item Item[]
}
model ReferralAct {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
referrerId Int
referrer User @relation(fields: [referrerId], references: [id])
itemActId Int
itemAct ItemAct @relation(fields: [itemActId], references: [id])
msats BigInt
}
enum ItemActType {
VOTE
BOOST
@ -325,6 +355,7 @@ model ItemAct {
itemId Int
user User @relation(fields: [userId], references: [id])
userId Int
ReferralAct ReferralAct[]
@@index([itemId])
@@index([userId])

1
svgs/share-fill.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13.576 17.271l-5.11-2.787a3.5 3.5 0 1 1 0-4.968l5.11-2.787a3.5 3.5 0 1 1 .958 1.755l-5.11 2.787a3.514 3.514 0 0 1 0 1.458l5.11 2.787a3.5 3.5 0 1 1-.958 1.755z"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M13 14h-2a8.999 8.999 0 0 0-7.968 4.81A10.136 10.136 0 0 1 3 18C3 12.477 7.477 8 13 8V3l10 8-10 8v-5z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -10,13 +10,20 @@ function earn ({ models }) {
console.log('running', name)
// compute how much sn earned today
const [{ sum }] = await models.$queryRaw`
SELECT sum("ItemAct".msats)
let [{ sum }] = await models.$queryRaw`
SELECT coalesce(sum("ItemAct".msats - coalesce("ReferralAct".msats, 0)), 0) as sum
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
LEFT JOIN "ReferralAct" ON "ItemAct".id = "ReferralAct"."itemActId"
WHERE "ItemAct".act <> 'TIP'
AND "ItemAct".created_at > now_utc() - INTERVAL '1 day'`
const [{ sum: donatedSum }] = await models.$queryRaw`
SELECT coalesce(sum(sats), 0) as sum
FROM "Donation"
WHERE created_at > now_utc() - INTERVAL '1 day'`
sum += donatedSum * 1000
/*
How earnings work:
1/3: top 21% posts over last 36 hours, scored on a relative basis