Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 58 additions & 5 deletions api/paidAction/zap.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { HALLOWEEN_IMMUNITY_HOURS, PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { notifyZapped } from '@/lib/webPush'
import { datePivot } from '@/lib/time'
import { notifyZapped, notifyInfected } from '@/lib/webPush'
import { getInvoiceableWallets } from '@/wallets/server'
import { Prisma } from '@prisma/client'

Expand Down Expand Up @@ -78,9 +79,12 @@ export async function perform ({ invoiceId, sats, id: itemId, ...args }, { me, c
]
})

const [{ path }] = await tx.$queryRaw`
SELECT ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`
return { id: itemId, sats, act: 'TIP', path, actIds: acts.map(act => act.id) }
const [{ userId, path }] = await tx.$queryRaw`
SELECT "userId", ltree2text(path) as path FROM "Item" WHERE id = ${itemId}::INTEGER`

const immune = await isImmune(userId, { tx })

return { id: itemId, sats, act: 'TIP', path, immune, actIds: acts.map(act => act.id) }
}

export async function retry ({ invoiceId, newInvoiceId }, { tx, cost }) {
Expand Down Expand Up @@ -216,6 +220,55 @@ export async function onPaid ({ invoice, actIds }, { tx }) {
SET "bountyPaidTo" = array_remove(array_append(array_remove("bountyPaidTo", bounty.target), bounty.target), NULL)
FROM bounty
WHERE "Item".id = bounty.id AND bounty.paid`

await maybeInfectUser(itemAct, { tx })
}

async function isImmune (userId, { tx }) {
// immunity lasts one hour less every day until a minimum of 1 hour is reached
const difficulty = Math.max(1, HALLOWEEN_IMMUNITY_HOURS - daysSinceHalloween())
const item = await tx.item.findFirst({
where: {
userId,
createdAt: { gt: datePivot(new Date(), { hours: -difficulty }) }
}
})
return !!item
}

function daysSinceHalloween () {
// return 0 if Halloween has not happened yet else return the days since Halloween
const diffTime = new Date().getTime() - new Date('2025-10-31').getTime()
return Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24)))
}

async function maybeInfectUser (itemAct, { tx }) {
// We added the 'infected' column to the users table so the query for users can continue
// to only fetch columns from the users table. We only use it for display purposes.
// The infection table is used to check if a user is infected and store additional information
// (who infected who when why).

const { id, userId: fromId, item: { userId: toId } } = itemAct
const infection = await tx.infection.findFirst({ where: { infecteeId: fromId } })
if (!infection) {
// zapper not infected, so can't infect other user
return
}

if (await isImmune(toId, { tx })) {
// user is immune because they created an item not too long ago
return
}

const count = await tx.$executeRaw`
INSERT INTO "Infection" ("itemActId", "infecteeId", "infectorId")
VALUES (${id}::INTEGER, ${toId}::INTEGER, ${fromId}::INTEGER)
ON CONFLICT ("infecteeId") DO NOTHING`
await tx.user.update({ where: { id: toId }, data: { infected: true } })

if (count > 0) {
notifyInfected(toId).catch(console.error)
}
}

export async function nonCriticalSideEffects ({ invoice, actIds }, { models }) {
Expand Down
8 changes: 8 additions & 0 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,14 @@ export default {
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT id::text, created_at AS "sortTime", 0 as "earnedSats", 'Infection' AS type
FROM "Infection"
WHERE "infecteeId" = $1
AND created_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
for (const type of ['HORSE', 'GUN']) {
const gqlType = type.charAt(0) + type.slice(1).toLowerCase()
queries.push(
Expand Down
13 changes: 13 additions & 0 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,19 @@ export default {
foundNotes()
return true
}

const infection = await models.infection.findFirst({
where: {
infecteeId: me.id,
createdAt: {
gt: lastChecked
}
}
})
if (infection) {
foundNotes()
return true
}
}

const subStatus = await models.sub.findFirst({
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default gql`
sats: Int!
path: String
act: String!
immune: Boolean
}

type ItemAct {
Expand Down
6 changes: 6 additions & 0 deletions api/typeDefs/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,17 @@ export default gql`
sortTime: Date!
}

type Infection {
id: ID!
sortTime: Date!
}

union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention | Invoicification
| ReferralReward | CowboyHat | NewHorse | LostHorse | NewGun | LostGun
| Infection

type Notifications {
lastChecked: Date
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export default gql`
streak: Int
gunStreak: Int
horseStreak: Int
infected: Boolean
hasSendWallet: Boolean
hasRecvWallet: Boolean
hideWalletRecvPrompt: Boolean
Expand Down
8 changes: 8 additions & 0 deletions components/badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AnonIcon from '@/svgs/spy-fill.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import BotIcon from '@/svgs/robot-2-fill.svg'
import BioHazardIcon from '@/svgs/biohazard.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import classNames from 'classnames'
Expand Down Expand Up @@ -47,6 +48,13 @@ export default function Badges ({ user, badge, bot, className = 'ms-1', badgeCla
})
}

if (user.optional.infected) {
badges.push({
icon: BioHazardIcon,
overlayText: 'infected'
})
}

if (bot) {
badges = [{
icon: BotIcon,
Expand Down
45 changes: 41 additions & 4 deletions components/item-act.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import gql from 'graphql-tag'
import { Form, Input, SubmitButton } from './form'
import { useMe } from './me'
import UpBolt from '@/svgs/bolt.svg'
Expand Down Expand Up @@ -110,10 +111,11 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}
}

const onPaid = () => {
const onPaid = (cache, { data } = {}) => {
animate()
onClose?.()
if (!me) setItemMeAnonSats({ id: item.id, amount })
if (cache && data) infectOnPaid(cache, { me, data })
}

const closeImmediately = hasSendWallet || me?.privates?.sats > Number(amount)
Expand All @@ -133,13 +135,16 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
act: {
__typename: 'ItemActPaidAction',
result: {
id: item.id, sats: Number(amount), act, path: item.path
id: item.id, sats: Number(amount), immune: true, act, path: item.path
}
}
}
: undefined,
// don't close modal immediately because we want the QR modal to stack
onPaid: closeImmediately ? undefined : onPaid
// but still trigger halloween infection
onPaid: closeImmediately
? (cache, { data }) => infectOnPaid(cache, { me, data })
: onPaid
})
if (error) throw error
addCustomTip(Number(amount))
Expand Down Expand Up @@ -292,6 +297,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
if (!response) return
updateAncestors(cache, response)
options?.onPaid?.(cache, { data })
infectOnPaid(cache, { data, me })
}
})
return act
Expand All @@ -310,7 +316,7 @@ export function useZap () {
const sats = nextTip(meSats, { ...me?.privates })

const variables = { id: item.id, sats, act: 'TIP', hasSendWallet }
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } }
const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, immune: true, ...variables } } }

try {
await abortSignal.pause({ me, amount: sats })
Expand Down Expand Up @@ -372,3 +378,34 @@ const zapUndo = async (signal, amount) => {
}, ZAP_UNDO_DELAY_MS)
})
}

const infectOnPaid = (cache, { data, me }) => {
const { act: { result } } = data

// anon is patient zero and therefore always infected
const infected = !me || me.optional.infected
if (!infected || result.immune) {
return
}

const itemId = Number(result.path.split('.').pop())
const item = cache.readFragment({
id: `Item:${itemId}`,
fragment: gql`
fragment InfectOnPaidItemFields on Item {
user {
id
}
}`
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Null Handling Error in infectOnPaid Function

The infectOnPaid function doesn't guard against cache.readFragment returning null when an item isn't in the Apollo cache. This causes a TypeError when accessing item.user.id, preventing the infection logic from completing.

Fix in Cursor Fix in Web

cache.writeFragment({
id: `User:${item.user.id}`,
fragment: gql`
fragment InfectOnPaidUserFields on User {
optional {
infected
}
}`,
data: { optional: { infected: true } }
})
}
20 changes: 19 additions & 1 deletion components/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import HolsterIcon from '@/svgs/holster.svg'
import SaddleIcon from '@/svgs/saddle.svg'
import CCInfo from './info/cc'
import { useMe } from './me'
import BioHazardIcon from '@/svgs/biohazard.svg'

function Notification ({ n, fresh }) {
const type = n.__typename
Expand All @@ -61,6 +62,7 @@ function Notification ({ n, fresh }) {
(type === 'CowboyHat' && <CowboyHat n={n} />) ||
(['NewHorse', 'LostHorse'].includes(type) && <Horse n={n} />) ||
(['NewGun', 'LostGun'].includes(type) && <Gun n={n} />) ||
(type === 'Infection' && <Infection n={n} />) ||
(type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
Expand Down Expand Up @@ -167,7 +169,7 @@ const defaultOnClick = n => {
if (type === 'WithdrawlPaid') return { href: `/withdrawals/${n.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'ReferralReward') return { href: '/referrals/month' }
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun'].includes(type)) return {}
if (['CowboyHat', 'NewHorse', 'LostHorse', 'NewGun', 'LostGun', 'Infection'].includes(type)) return {}
if (type === 'TerritoryTransfer') return { href: `/~${n.sub.name}` }

if (!n.item) return {}
Expand Down Expand Up @@ -240,6 +242,22 @@ function Gun ({ n }) {
)
}

function Infection ({ n }) {
// TODO: use random blurbs?
return (
<div className='d-flex'>
<BioHazardIcon className='fill-grey mx-1' width={40} height={40} />
<div className='ms-2'>
<NoteHeader big>
you have been bitten by a zombie
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</NoteHeader>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>You're feeling cold and clammy and you have a sudden desire to zap everyone ...</small></div>
</div>
</div>
)
}

function EarnNotification ({ n }) {
const time = n.minSortTime === n.sortTime ? dayMonthYear(new Date(n.minSortTime)) : `${dayMonthYear(new Date(n.minSortTime))} to ${dayMonthYear(new Date(n.sortTime))}`

Expand Down
1 change: 1 addition & 0 deletions fragments/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const STREAK_FIELDS = gql`
streak
hasSendWallet
hasRecvWallet
infected
}
}
`
Expand Down
1 change: 1 addition & 0 deletions fragments/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const STREAK_FIELDS = gql`
streak
hasSendWallet
hasRecvWallet
infected
}
}
`
Expand Down
4 changes: 4 additions & 0 deletions fragments/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export const NOTIFICATIONS = gql`
id
sortTime
}
... on Infection {
id
sortTime
}
... on Earn {
id
sortTime
Expand Down
1 change: 1 addition & 0 deletions fragments/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const ITEM_ACT_PAID_ACTION_FIELDS = gql`
sats
path
act
immune
}
}`

Expand Down
1 change: 1 addition & 0 deletions fragments/subs.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const STREAK_FIELDS = gql`
streak
hasSendWallet
hasRecvWallet
infected
}
}
`
Expand Down
1 change: 1 addition & 0 deletions fragments/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const STREAK_FIELDS = gql`
streak
hasSendWallet
hasRecvWallet
infected
}
}
`
Expand Down
1 change: 1 addition & 0 deletions lib/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function getClient (uri) {
'LostHorse',
'NewGun',
'LostGun',
'Infection',
'FollowActivity',
'ForwardedVotification',
'Revenue',
Expand Down
3 changes: 3 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,6 @@ export const WALLET_MAX_RETRIES = 2
export const WALLET_RETRY_TIMEOUT_MS = 60_000 // 1 minute

export const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'

export const HALLOWEEN_IMMUNITY_HOURS = 8
export const HALLOWEEN_INACTIVITY_TIMEOUT_HOURS = 36
11 changes: 11 additions & 0 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,14 @@ export async function notifyReminder ({ userId, item }) {
itemId: item.id
})
}

export async function notifyInfected (userId) {
try {
await sendUserNotification(userId, {
title: 'you have been bitten by a zombie',
body: 'You\'re feeling cold and clammy and you have a sudden desire to zap everyone ...'
})
} catch (err) {
log('error sending infected notification:', err)
}
}
Loading