Compare commits

...

6 Commits

Author SHA1 Message Date
Keyan 245419185f
wallet streaks (#1468)
* wallet streaks backend

* notifications and badges

* reuseable streak fragment

* squash migrations

* push notifications

* update cowboy notification setting label text
2024-10-11 19:14:18 -05:00
k00b 915fc87596 tradeoff memory for throughput in prod 2024-10-10 18:11:39 -05:00
k00b ce2d7e5791 try to fix apollo leak 2024-10-10 16:03:21 -05:00
k00b d6d4f01b45 remove prod debug of cached fetcher 2024-10-10 10:44:11 -05:00
k00b c634c61dd2 cached fetcher debug env var 2024-10-10 09:35:39 -05:00
ekzyis 7eaaa7ce44
Fix sub?.removeAllListeners is not a function (#1469) 2024-10-09 20:13:53 -05:00
38 changed files with 585 additions and 327 deletions

View File

@ -21,4 +21,4 @@ PRISMA_SLOW_LOGS_MS=50
GRAPHQL_SLOW_LOGS_MS=50 GRAPHQL_SLOW_LOGS_MS=50
DB_APP_CONNECTION_LIMIT=4 DB_APP_CONNECTION_LIMIT=4
DB_WORKER_CONNECTION_LIMIT=2 DB_WORKER_CONNECTION_LIMIT=2
DB_TRANSACTION_TIMEOUT=10000 DB_TRANSACTION_TIMEOUT=10000

View File

@ -465,6 +465,14 @@ export default {
` `
return res.length ? res[0].days : null return res.length ? res[0].days : null
},
type: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "type"
FROM "Streak"
WHERE id = ${Number(n.id)}
`
return res.length ? res[0].type : null
} }
}, },
Earn: { Earn: {

View File

@ -49,6 +49,8 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
} }
} }
}) })
await client.clearStore()
return client return client
} }

View File

@ -79,6 +79,7 @@ export default gql`
id: ID! id: ID!
sortTime: Date! sortTime: Date!
days: Int days: Int
type: String!
} }
type Earn { type Earn {

View File

@ -192,6 +192,8 @@ export default gql`
spent(when: String, from: String, to: String): Int spent(when: String, from: String, to: String): Int
referrals(when: String, from: String, to: String): Int referrals(when: String, from: String, to: String): Int
streak: Int streak: Int
gunStreak: Int
horseStreak: Int
maxStreak: Int maxStreak: Int
isContributor: Boolean isContributor: Boolean
githubId: String githubId: String

93
components/badge.js Normal file
View File

@ -0,0 +1,93 @@
import Badge from 'react-bootstrap/Badge'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import classNames from 'classnames'
const BADGES = [
{
icon: CowboyHatIcon,
streakName: 'streak'
},
{
icon: HorseIcon,
streakName: 'horseStreak'
},
{
icon: GunIcon,
streakName: 'gunStreak',
sizeDelta: 2
}
]
export default function Badges ({ user, badge, className = 'ms-1', badgeClassName, spacingClassName = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<BadgeTooltip overlayText='anonymous'>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<AnonIcon className={`${badgeClassName} fill-dark align-middle`} height={height} width={width} />
</Badge>)
: <span><AnonIcon className={`${badgeClassName} align-middle`} height={height} width={width} /></span>}
</BadgeTooltip>
)
}
return (
<span className={className}>
{BADGES.map(({ icon, streakName, sizeDelta }, i) => (
<SNBadge
key={streakName}
user={user}
badge={badge}
streakName={streakName}
badgeClassName={classNames(badgeClassName, i > 0 && spacingClassName)}
IconForBadge={icon}
height={height}
width={width}
sizeDelta={sizeDelta}
/>
))}
</span>
)
}
function SNBadge ({ user, badge, streakName, badgeClassName, IconForBadge, height = 16, width = 16, sizeDelta = 0 }) {
const streak = user.optional[streakName]
if (streak === null) {
return null
}
return (
<BadgeTooltip
overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
<span><IconForBadge className={badgeClassName} height={height + sizeDelta} width={width + sizeDelta} /></span>
</BadgeTooltip>
)
}
export function BadgeTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -1,59 +0,0 @@
import Badge from 'react-bootstrap/Badge'
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
import Tooltip from 'react-bootstrap/Tooltip'
import CowboyHatIcon from '@/svgs/cowboy.svg'
import AnonIcon from '@/svgs/spy-fill.svg'
import { numWithUnits } from '@/lib/format'
import { USER_ID } from '@/lib/constants'
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<HatTooltip overlayText='anonymous'>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<AnonIcon className={`${className} fill-dark align-middle`} height={height} width={width} />
</Badge>)
: <span><AnonIcon className={`${className} align-middle`} height={height} width={width} /></span>}
</HatTooltip>
)
}
const streak = user.optional.streak
if (streak === null) {
return null
}
return (
<HatTooltip overlayText={streak
? `${numWithUnits(streak, { abbreviate: false, unitSingular: 'day', unitPlural: 'days' })}`
: 'new'}
>
{badge
? (
<Badge bg='grey-medium' className='ms-2 d-inline-flex align-items-center'>
<CowboyHatIcon className={`${className} fill-dark`} height={height} width={width} />
<span className='ms-1 text-dark'>{streak || 'new'}</span>
</Badge>)
: <span><CowboyHatIcon className={className} height={height} width={width} /></span>}
</HatTooltip>
)
}
export function HatTooltip ({ children, overlayText, placement }) {
return (
<OverlayTrigger
placement={placement || 'bottom'}
overlay={
<Tooltip>
{overlayText}
</Tooltip>
}
trigger={['hover', 'focus']}
>
{children}
</OverlayTrigger>
)
}

View File

@ -14,7 +14,7 @@ import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this'
import BookmarkDropdownItem from './bookmark' import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe' import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share' import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat' import Badges from './badge'
import { USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute' import MuteDropdownItem from './mute'
@ -111,12 +111,11 @@ export default function ItemInfo ({
<span> \ </span> <span> \ </span>
<span> <span>
{showUser && {showUser &&
<UserPopover name={item.user.name}> <Link href={`/${item.user.name}`}>
<Link href={`/${item.user.name}`}> <UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} /> <Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
{embellishUser} {embellishUser}
</Link> </Link>}
</UserPopover>}
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))} {timeSince(new Date(item.createdAt))}

View File

@ -8,7 +8,7 @@ import Link from 'next/link'
import { timeSince } from '@/lib/time' import { timeSince } from '@/lib/time'
import EmailIcon from '@/svgs/mail-open-line.svg' import EmailIcon from '@/svgs/mail-open-line.svg'
import Share from './share' import Share from './share'
import Hat from './hat' import Badges from './badge'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format' import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap' import { Badge } from 'react-bootstrap'
@ -54,7 +54,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
{item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>} {item.boost > 0 && <span>{abbrNum(item.boost)} boost \ </span>}
<span> <span>
<Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'> <Link href={`/${item.user.name}`} className='d-inline-flex align-items-center'>
@{item.user.name}<Hat className='ms-1 fill-grey' user={item.user} height={12} width={12} /> @{item.user.name}<Badges badgeClassName='fill-grey' height={12} width={12} user={item.user} />
</Link> </Link>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>

View File

@ -14,7 +14,7 @@ import HiddenWalletSummary from '../hidden-wallet-summary'
import { abbrNum, msatsToSats } from '../../lib/format' import { abbrNum, msatsToSats } from '../../lib/format'
import { useServiceWorker } from '../serviceworker' import { useServiceWorker } from '../serviceworker'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import Hat from '../hat' import Badges from '../badge'
import { randInRange } from '../../lib/rand' import { randInRange } from '../../lib/rand'
import { useLightning } from '../lightning' import { useLightning } from '../lightning'
import LightningIcon from '../../svgs/bolt.svg' import LightningIcon from '../../svgs/bolt.svg'
@ -165,12 +165,21 @@ export function NavWalletSummary ({ className }) {
export function MeDropdown ({ me, dropNavKey }) { export function MeDropdown ({ me, dropNavKey }) {
if (!me) return null if (!me) return null
return ( return (
<div className='position-relative'> <div className=''>
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item fw-normal' id='profile' variant='custom'>
<Nav.Link eventKey={me.name} as='span' className='p-0'> <div className='d-flex align-items-center'>
{`@${me.name}`}<Hat user={me} /> <Nav.Link eventKey={me.name} as='span' className='p-0 position-relative'>
</Nav.Link> {`@${me.name}`}
{!me.bioId &&
<span className='d-inline-block p-1'>
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px', height: '5px', width: '5px' }}>
<span className='invisible'>{' '}</span>
</span>
</span>}
</Nav.Link>
<Badges user={me} />
</div>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
<Link href={'/' + me.name} passHref legacyBehavior> <Link href={'/' + me.name} passHref legacyBehavior>
@ -205,10 +214,6 @@ export function MeDropdown ({ me, dropNavKey }) {
<LogoutDropdownItem /> <LogoutDropdownItem />
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
{!me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div> </div>
) )
} }
@ -377,7 +382,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end' autoClose> <Dropdown className={styles.dropdown} align='end' autoClose>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'> <Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: USER_ID.anon }} /> @anon<Badges user={{ id: USER_ID.anon }} />
</Nav.Link> </Nav.Link>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='p-3'> <Dropdown.Menu className='p-3'>

View File

@ -14,6 +14,8 @@ import UserAdd from '@/svgs/user-add-fill.svg'
import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants' import { LOST_BLURBS, FOUND_BLURBS, UNKNOWN_LINK_REL } from '@/lib/constants'
import CowboyHatIcon from '@/svgs/cowboy.svg' import CowboyHatIcon from '@/svgs/cowboy.svg'
import BaldIcon from '@/svgs/bald.svg' import BaldIcon from '@/svgs/bald.svg'
import GunIcon from '@/svgs/revolver.svg'
import HorseIcon from '@/svgs/horse.svg'
import { RootProvider } from './root' import { RootProvider } from './root'
import Alert from 'react-bootstrap/Alert' import Alert from 'react-bootstrap/Alert'
import styles from './notifications.module.css' import styles from './notifications.module.css'
@ -39,6 +41,8 @@ import { useRetryCreateItem } from './use-item-submit'
import { payBountyCacheMods } from './pay-bounty' import { payBountyCacheMods } from './pay-bounty'
import { useToast } from './toast' import { useToast } from './toast'
import classNames from 'classnames' import classNames from 'classnames'
import HolsterIcon from '@/svgs/holster.svg'
import SaddleIcon from '@/svgs/saddle.svg'
function Notification ({ n, fresh }) { function Notification ({ n, fresh }) {
const type = n.__typename const type = n.__typename
@ -168,23 +172,28 @@ const defaultOnClick = n => {
function Streak ({ n }) { function Streak ({ n }) {
function blurb (n) { function blurb (n) {
const index = Number(n.id) % Math.min(FOUND_BLURBS.length, LOST_BLURBS.length) const type = n.type ?? 'COWBOY_HAT'
const index = Number(n.id) % Math.min(FOUND_BLURBS[type].length, LOST_BLURBS[type].length)
if (n.days) { if (n.days) {
return `After ${numWithUnits(n.days, { return `After ${numWithUnits(n.days, {
abbreviate: false, abbreviate: false,
unitSingular: 'day', unitSingular: 'day',
unitPlural: 'days' unitPlural: 'days'
})}, ` + LOST_BLURBS[index] })}, ` + LOST_BLURBS[type][index]
} }
return FOUND_BLURBS[index] return FOUND_BLURBS[type][index]
} }
const Icon = n.days
? n.type === 'GUN' ? HolsterIcon : n.type === 'HORSE' ? SaddleIcon : BaldIcon
: n.type === 'GUN' ? GunIcon : n.type === 'HORSE' ? HorseIcon : CowboyHatIcon
return ( return (
<div className='d-flex'> <div className='d-flex'>
<div style={{ fontSize: '2rem' }}>{n.days ? <BaldIcon className='fill-grey' height={40} width={40} /> : <CowboyHatIcon className='fill-grey' height={40} width={40} />}</div> <div style={{ fontSize: '2rem' }}><Icon className='fill-grey' height={40} width={40} /></div>
<div className='ms-1 p-1'> <div className='ms-1 p-1'>
<span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} cowboy hat</span> <span className='fw-bold'>you {n.days ? 'lost your' : 'found a'} {n.type.toLowerCase().replace('_', ' ')}</span>
<div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div> <div><small style={{ lineHeight: '140%', display: 'inline-block' }}>{blurb(n)}</small></div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import Link from 'next/link'
import Text from './text' import Text from './text'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import styles from './item.module.css' import styles from './item.module.css'
import Hat from './hat' import Badges from './badge'
import { useMe } from './me' import { useMe } from './me'
import Share from './share' import Share from './share'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
@ -41,7 +41,7 @@ export function TerritoryInfo ({ sub }) {
<div className='text-muted'> <div className='text-muted'>
<span>founded by </span> <span>founded by </span>
<Link href={`/${sub.user.name}`}> <Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} /> @{sub.user.name}<Badges badgeClassName='fill-grey' height={12} width={12} user={sub.user} />
</Link> </Link>
<span> on </span> <span> on </span>
<span className='fw-bold'>{new Date(sub.createdAt).toDateString()}</span> <span className='fw-bold'>{new Date(sub.createdAt).toDateString()}</span>

View File

@ -17,7 +17,7 @@ import Avatar from './avatar'
import { userSchema } from '@/lib/validate' import { userSchema } from '@/lib/validate'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import Hat from './hat' import Badges from './badge'
import SubscribeUserDropdownItem from './subscribeUser' import SubscribeUserDropdownItem from './subscribeUser'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
import CodeIcon from '@/svgs/terminal-box-fill.svg' import CodeIcon from '@/svgs/terminal-box-fill.svg'
@ -178,7 +178,7 @@ function NymView ({ user, isMe, setEditting }) {
const { me } = useMe() const { me } = useMe()
return ( return (
<div className='d-flex align-items-center mb-2'> <div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div> <div className={styles.username}>@{user.name}<Badges className='ms-2' user={user} badgeClassName='fill-grey' /></div>
{isMe && {isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>} <Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
{!isMe && me && <NymActionDropdown user={user} />} {!isMe && me && <NymActionDropdown user={user} />}

View File

@ -7,7 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import { useData } from './use-data' import { useData } from './use-data'
import Hat from './hat' import Badges from './badge'
import { useMe } from './me' import { useMe } from './me'
import { MEDIA_URL } from '@/lib/constants' import { MEDIA_URL } from '@/lib/constants'
import { NymActionDropdown } from '@/components/user-header' import { NymActionDropdown } from '@/components/user-header'
@ -57,7 +57,7 @@ export function UserListRow ({ user, stats, className, onNymClick, showHat = tru
style={{ textUnderlineOffset: '0.25em' }} style={{ textUnderlineOffset: '0.25em' }}
onClick={onNymClick} onClick={onNymClick}
> >
@{user.name}{showHat && <Hat className='ms-1 fill-grey' height={14} width={14} user={user} />}{selected && <CheckCircle className='ms-3 fill-primary' height={14} width={14} />} @{user.name}{showHat && <Badges badgeClassName='fill-grey' height={14} width={14} user={user} />}{selected && <CheckCircle className='ms-3 fill-primary' height={14} width={14} />}
</Link> </Link>
{stats && ( {stats && (
<div className={styles.other}> <div className={styles.other}>
@ -81,7 +81,7 @@ export function UserBase ({ user, className, children, nymActionDropdown }) {
<div className={styles.hunk}> <div className={styles.hunk}>
<div className='d-flex'> <div className='d-flex'>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}> <Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
@{user.name}<Hat className='ms-1 fill-grey' height={14} width={14} user={user} /> @{user.name}<Badges badgeClassName='fill-grey' height={14} width={14} user={user} />
</Link> </Link>
{nymActionDropdown && <NymActionDropdown user={user} className='' />} {nymActionDropdown && <NymActionDropdown user={user} className='' />}
</div> </div>

View File

@ -1,6 +1,18 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
// we can't import from users because of circular dependency
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
}
}
`
export const COMMENT_FIELDS = gql` export const COMMENT_FIELDS = gql`
${STREAK_FIELDS}
fragment CommentFields on Item { fragment CommentFields on Item {
id id
position position
@ -11,10 +23,8 @@ export const COMMENT_FIELDS = gql`
user { user {
id id
name name
optional {
streak
}
meMute meMute
...StreakFields
} }
sats sats
meAnonSats @client meAnonSats @client
@ -45,6 +55,7 @@ export const COMMENT_FIELDS = gql`
` `
export const COMMENTS_ITEM_EXT_FIELDS = gql` export const COMMENTS_ITEM_EXT_FIELDS = gql`
${STREAK_FIELDS}
fragment CommentItemExtFields on Item { fragment CommentItemExtFields on Item {
text text
root { root {
@ -61,10 +72,8 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
} }
user { user {
name name
optional {
streak
}
id id
...StreakFields
} }
} }
}` }`

View File

@ -1,6 +1,8 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { STREAK_FIELDS } from './users'
export const INVITE_FIELDS = gql` export const INVITE_FIELDS = gql`
${STREAK_FIELDS}
fragment InviteFields on Invite { fragment InviteFields on Invite {
id id
createdAt createdAt
@ -14,9 +16,7 @@ export const INVITE_FIELDS = gql`
user { user {
id id
name name
optional { ...StreakFields
streak
}
} }
poor poor
} }

View File

@ -1,7 +1,19 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { COMMENTS } from './comments' import { COMMENTS } from './comments'
// we can't import from users because of circular dependency
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
}
}
`
export const ITEM_FIELDS = gql` export const ITEM_FIELDS = gql`
${STREAK_FIELDS}
fragment ItemFields on Item { fragment ItemFields on Item {
id id
parentId parentId
@ -12,10 +24,8 @@ export const ITEM_FIELDS = gql`
user { user {
id id
name name
optional {
streak
}
meMute meMute
...StreakFields
} }
sub { sub {
name name
@ -69,6 +79,7 @@ export const ITEM_FIELDS = gql`
export const ITEM_FULL_FIELDS = gql` export const ITEM_FULL_FIELDS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
${STREAK_FIELDS}
fragment ItemFullFields on Item { fragment ItemFullFields on Item {
...ItemFields ...ItemFields
text text
@ -82,9 +93,7 @@ export const ITEM_FULL_FIELDS = gql`
user { user {
id id
name name
optional { ...StreakFields
streak
}
} }
sub { sub {
name name

View File

@ -86,6 +86,7 @@ export const NOTIFICATIONS = gql`
id id
sortTime sortTime
days days
type
} }
... on Earn { ... on Earn {
id id

View File

@ -2,6 +2,17 @@ import { gql } from '@apollo/client'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items' import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { COMMENTS_ITEM_EXT_FIELDS } from './comments' import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
// we can't import from users because of circular dependency
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
}
}
`
export const SUB_FIELDS = gql` export const SUB_FIELDS = gql`
fragment SubFields on Sub { fragment SubFields on Sub {
name name
@ -26,15 +37,13 @@ export const SUB_FIELDS = gql`
export const SUB_FULL_FIELDS = gql` export const SUB_FULL_FIELDS = gql`
${SUB_FIELDS} ${SUB_FIELDS}
${STREAK_FIELDS}
fragment SubFullFields on Sub { fragment SubFullFields on Sub {
...SubFields ...SubFields
user { user {
name name
id id
optional { ...StreakFields
streak
}
} }
}` }`

View File

@ -3,47 +3,58 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items' import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { SUB_FULL_FIELDS } from './subs' import { SUB_FULL_FIELDS } from './subs'
export const ME = gql` export const STREAK_FIELDS = gql`
{ fragment StreakFields on User {
me { optional {
id streak
name gunStreak
bioId horseStreak
photoId
privates {
autoDropBolt11s
diagnostics
noReferralLinks
fiatCurrency
autoWithdrawMaxFeePercent
autoWithdrawThreshold
withdrawMaxFeeDefault
satsFilter
hideFromTopUsers
hideWalletBalance
hideWelcomeBanner
imgproxyOnly
showImagesAndVideos
nostrCrossposting
sats
tipDefault
tipRandom
tipRandomMin
tipRandomMax
tipPopover
turboTipping
zapUndos
upvotePopover
wildWestMode
disableFreebies
}
optional {
isContributor
stacked
streak
}
} }
}` }
`
export const ME = gql`
${STREAK_FIELDS}
{
me {
id
name
bioId
photoId
privates {
autoDropBolt11s
diagnostics
noReferralLinks
fiatCurrency
autoWithdrawMaxFeePercent
autoWithdrawThreshold
withdrawMaxFeeDefault
satsFilter
hideFromTopUsers
hideWalletBalance
hideWelcomeBanner
imgproxyOnly
showImagesAndVideos
nostrCrossposting
sats
tipDefault
tipRandom
tipRandomMin
tipRandomMax
tipPopover
turboTipping
zapUndos
upvotePopover
wildWestMode
disableFreebies
}
optional {
isContributor
stacked
}
...StreakFields
}
}`
export const SETTINGS_FIELDS = gql` export const SETTINGS_FIELDS = gql`
fragment SettingsFields on User { fragment SettingsFields on User {
@ -101,61 +112,52 @@ export const SETTINGS_FIELDS = gql`
}` }`
export const SETTINGS = gql` export const SETTINGS = gql`
${SETTINGS_FIELDS} ${SETTINGS_FIELDS}
query Settings { query Settings {
settings { settings {
...SettingsFields ...SettingsFields
} }
}` }`
export const SET_SETTINGS = export const SET_SETTINGS = gql`
gql` ${SETTINGS_FIELDS}
${SETTINGS_FIELDS} mutation setSettings($settings: SettingsInput!) {
mutation setSettings($settings: SettingsInput!) { setSettings(settings: $settings) {
setSettings(settings: $settings) { ...SettingsFields
...SettingsFields }
} }`
}
`
export const DELETE_WALLET = export const DELETE_WALLET = gql`
gql` mutation removeWallet {
mutation removeWallet { removeWallet
removeWallet }`
}
`
export const NAME_QUERY = export const NAME_QUERY = gql`
gql`
query nameAvailable($name: String!) { query nameAvailable($name: String!) {
nameAvailable(name: $name) nameAvailable(name: $name)
} }`
`
export const NAME_MUTATION = export const NAME_MUTATION = gql`
gql`
mutation setName($name: String!) { mutation setName($name: String!) {
setName(name: $name) setName(name: $name)
} }
` `
export const WELCOME_BANNER_MUTATION = export const WELCOME_BANNER_MUTATION = gql`
gql`
mutation hideWelcomeBanner { mutation hideWelcomeBanner {
hideWelcomeBanner hideWelcomeBanner
} }
` `
export const USER_SUGGESTIONS = export const USER_SUGGESTIONS = gql`
gql`
query userSuggestions($q: String!, $limit: Limit) { query userSuggestions($q: String!, $limit: Limit) {
userSuggestions(q: $q, limit: $limit) { userSuggestions(q: $q, limit: $limit) {
name name
} }
}` }`
export const USER_SEARCH = export const USER_SEARCH = gql`
gql` ${STREAK_FIELDS}
query searchUsers($q: String!, $limit: Limit, $similarity: Float) { query searchUsers($q: String!, $limit: Limit, $similarity: Float) {
searchUsers(q: $q, limit: $limit, similarity: $similarity) { searchUsers(q: $q, limit: $limit, similarity: $similarity) {
id id
@ -165,15 +167,16 @@ gql`
nposts nposts
optional { optional {
streak
stacked stacked
spent spent
referrals referrals
} }
...StreakFields
} }
}` }`
export const USER_FIELDS = gql` export const USER_FIELDS = gql`
${STREAK_FIELDS}
fragment UserFields on User { fragment UserFields on User {
id id
name name
@ -187,16 +190,17 @@ export const USER_FIELDS = gql`
optional { optional {
stacked stacked
streak
maxStreak maxStreak
isContributor isContributor
githubId githubId
nostrAuthPubkey nostrAuthPubkey
twitterId twitterId
} }
...StreakFields
}` }`
export const MY_SUBSCRIBED_USERS = gql` export const MY_SUBSCRIBED_USERS = gql`
${STREAK_FIELDS}
query MySubscribedUsers($cursor: String) { query MySubscribedUsers($cursor: String) {
mySubscribedUsers(cursor: $cursor) { mySubscribedUsers(cursor: $cursor) {
users { users {
@ -207,9 +211,7 @@ export const MY_SUBSCRIBED_USERS = gql`
meSubscriptionComments meSubscriptionComments
meMute meMute
optional { ...StreakFields
streak
}
} }
cursor cursor
} }
@ -217,6 +219,7 @@ export const MY_SUBSCRIBED_USERS = gql`
` `
export const MY_MUTED_USERS = gql` export const MY_MUTED_USERS = gql`
${STREAK_FIELDS}
query MyMutedUsers($cursor: String) { query MyMutedUsers($cursor: String) {
myMutedUsers(cursor: $cursor) { myMutedUsers(cursor: $cursor) {
users { users {
@ -226,10 +229,7 @@ export const MY_MUTED_USERS = gql`
meSubscriptionPosts meSubscriptionPosts
meSubscriptionComments meSubscriptionComments
meMute meMute
...StreakFields
optional {
streak
}
} }
cursor cursor
} }
@ -237,6 +237,7 @@ export const MY_MUTED_USERS = gql`
` `
export const TOP_USERS = gql` export const TOP_USERS = gql`
${STREAK_FIELDS}
query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) { query TopUsers($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) { topUsers(cursor: $cursor, when: $when, from: $from, to: $to, by: $by) {
users { users {
@ -247,11 +248,11 @@ export const TOP_USERS = gql`
nposts(when: $when, from: $from, to: $to) nposts(when: $when, from: $from, to: $to)
optional { optional {
streak
stacked(when: $when, from: $from, to: $to) stacked(when: $when, from: $from, to: $to)
spent(when: $when, from: $from, to: $to) spent(when: $when, from: $from, to: $to)
referrals(when: $when, from: $from, to: $to) referrals(when: $when, from: $from, to: $to)
} }
...StreakFields
} }
cursor cursor
} }
@ -259,6 +260,7 @@ export const TOP_USERS = gql`
` `
export const TOP_COWBOYS = gql` export const TOP_COWBOYS = gql`
${STREAK_FIELDS}
query TopCowboys($cursor: String) { query TopCowboys($cursor: String) {
topCowboys(cursor: $cursor) { topCowboys(cursor: $cursor) {
users { users {
@ -269,11 +271,11 @@ export const TOP_COWBOYS = gql`
nposts(when: "forever") nposts(when: "forever")
optional { optional {
streak
stacked(when: "forever") stacked(when: "forever")
spent(when: "forever") spent(when: "forever")
referrals(when: "forever") referrals(when: "forever")
} }
...StreakFields
} }
cursor cursor
} }

View File

@ -119,22 +119,54 @@ export const TERRITORY_PERIOD_COST = (billingType) => {
} }
} }
export const FOUND_BLURBS = [ export const FOUND_BLURBS = {
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.', COWBOY_HAT: [
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.', 'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
"This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.", 'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',
"A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.", "This is not just a hat, it's a matter of survival. Take care of this essential tool, and it will shield you from the scorching sun and the elements.",
"A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.", "A cowboy hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.",
'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.' "A good cowboy hat is worth its weight in gold, shielding you from the sun, wind, and dust of the western frontier. Don't lose it.",
] 'Your cowboy hat is the key to your survival in the wild west. Treat it with respect and it will protect you from the elements.'
export const LOST_BLURBS = [ ],
'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.', GUN: [
"you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.", 'A gun is a tool, and like all tools, it can be used for good or evil. Use it wisely.',
'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, tIme to start searching for another one.', 'In these wild lands, a gun can be your best friend or worst enemy. Handle it with care and respect.',
'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.', 'This firearm is more than just a weapon; it\'s your lifeline in the untamed West. Treat it well.',
"your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.", 'A gun in the right hands can mean the difference between life and death. Make sure your aim is true.',
'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.' 'This gun is your ticket to survival in the frontier. Treat it with care and respect.'
] ],
HORSE: [
'A loyal steed is worth its weight in gold. Treat this horse well, and it\'ll carry you through thick and thin.',
'From dusty trails to raging rivers, this horse will be your constant companion. Treat it with respect.',
'This horse has chosen you as much as you\'ve chosen it. Together, you\'ll forge a path through the frontier.',
'Your new horse is both transportation and friend. In the loneliness of the prairie, you\'ll be glad for its company.',
'Swift hooves and a sturdy back - this horse has the spirit of the West. Ride it with pride and care.'
]
}
export const LOST_BLURBS = {
COWBOY_HAT: [
'your cowboy hat was taken by the wind storm that blew in from the west. No worries, a true cowboy always finds another hat.',
"you left your trusty cowboy hat in the saloon before leaving town. You'll need a replacement for the long journey west.",
'you lost your cowboy hat in a wild shoot-out on the outskirts of town. Tough luck, time to start searching for another one.',
'you ran out of food and had to trade your hat for supplies. Better start looking for another hat.',
"your hat was stolen by a mischievous prairie dog. You won't catch the dog, but you can always find another hat.",
'you lost your hat while crossing the river on your journey west. Maybe you can find a replacement hat in the next town.'
],
GUN: [
'your gun slipped from its holster while crossing a treacherous ravine. It\'s lost to the depths, but a new one awaits in the next town.',
'you were forced to toss your gun to distract a grizzly bear. It saved your life, but now you\'ll need to find a new firearm.',
'your gun was confiscated by the local sheriff after a misunderstanding. Time to clear your name and find a new sidearm.',
'your trusty six-shooter jammed beyond repair during a shootout. Luckily you survived, but now you need a replacement.',
'you traded your gun for medicine to save a sick child. A noble deed, but the frontier is unforgiving - best find a new weapon soon.'
],
HORSE: [
'your horse spooked at a rattlesnake and bolted into the night. You\'ll need to find a new steed to continue your journey.',
'you lost your horse in a game of chance. The stakes were high, but now you\'re on foot until you can acquire a new mount.',
'your horse was stolen by bandits while you slept. Time to track down a new companion for the long road ahead.',
'your loyal steed fell ill and you had to leave it at a ranch to recover. You\'ll need a new horse to press on with your travels.',
'your horse was requisitioned by the cavalry for an urgent mission. They left you with a voucher, but you\'ll need to find a new mount soon.'
]
}
export const ADMIN_ITEMS = [ export const ADMIN_ITEMS = [
// FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy // FAQ, old privacy policy, changelog, content guidelines, tos, new privacy policy, copyright policy

View File

@ -98,7 +98,10 @@ function createDebugLogger (name, cache, debug) {
} }
} }
export function cachedFetcher (fetcher, { maxSize = 100, cacheExpiry, forceRefreshThreshold, keyGenerator, debug = false }) { export function cachedFetcher (fetcher, {
maxSize = 100, cacheExpiry, forceRefreshThreshold,
keyGenerator, debug = process.env.DEBUG_CACHED_FETCHER
}) {
const cache = new LRUCache(maxSize) const cache = new LRUCache(maxSize)
const name = fetcher.name || fetcher.toString().slice(0, 20).replace(/\s+/g, '_') const name = fetcher.name || fetcher.toString().slice(0, 20).replace(/\s+/g, '_')
const logger = createDebugLogger(name, cache, debug) const logger = createDebugLogger(name, cache, debug)

View File

@ -363,14 +363,14 @@ export async function notifyWithdrawal (userId, wdrwl) {
} }
export async function notifyNewStreak (userId, streak) { export async function notifyNewStreak (userId, streak) {
const index = streak.id % FOUND_BLURBS.length const index = streak.id % FOUND_BLURBS[streak.type].length
const blurb = FOUND_BLURBS[index] const blurb = FOUND_BLURBS[streak.type][index]
try { try {
await sendUserNotification(userId, { await sendUserNotification(userId, {
title: 'you found a cowboy hat', title: `you found a ${streak.type.toLowerCase().replace('_', ' ')}`,
body: blurb, body: blurb,
tag: 'STREAK-FOUND' tag: `STREAK-FOUND-${streak.type}`
}) })
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -378,14 +378,14 @@ export async function notifyNewStreak (userId, streak) {
} }
export async function notifyStreakLost (userId, streak) { export async function notifyStreakLost (userId, streak) {
const index = streak.id % LOST_BLURBS.length const index = streak.id % LOST_BLURBS[streak.type].length
const blurb = LOST_BLURBS[index] const blurb = LOST_BLURBS[streak.type][index]
try { try {
await sendUserNotification(userId, { await sendUserNotification(userId, {
title: 'you lost your cowboy hat', title: `you lost your ${streak.type.toLowerCase().replace('_', ' ')}`,
body: blurb, body: blurb,
tag: 'STREAK-LOST' tag: `STREAK-LOST-${streak.type}`
}) })
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@ -6,7 +6,7 @@
"dev": "NODE_OPTIONS='--trace-warnings' next dev", "dev": "NODE_OPTIONS='--trace-warnings' next dev",
"build": "next build", "build": "next build",
"migrate": "prisma migrate deploy", "migrate": "prisma migrate deploy",
"start": "NODE_OPTIONS='--trace-warnings --max-old-space-size=4096' next start -p $PORT --keepAliveTimeout 120000", "start": "NODE_OPTIONS='--trace-warnings --max-old-space-size=4096 --max-semi-space-size=128' next start -p $PORT --keepAliveTimeout 120000",
"lint": "standard", "lint": "standard",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest", "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"worker": "tsx --tsconfig jsconfig.json --trace-warnings worker/index.js", "worker": "tsx --tsconfig jsconfig.json --trace-warnings worker/index.js",

View File

@ -53,6 +53,8 @@ ${ITEM_FULL_FIELDS}
optional { optional {
streak streak
gunStreak
horseStreak
stacked stacked
spent spent
referrals referrals

View File

@ -324,7 +324,7 @@ export default function Settings ({ ssrData }) {
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox <Checkbox
label='I find or lose a cowboy hat' label='I find or lose cowboy essentials (e.g. cowboy hat)'
name='noteCowboyHat' name='noteCowboyHat'
/> />
<div className='form-label'>privacy</div> <div className='form-label'>privacy</div>

View File

@ -0,0 +1,27 @@
-- CreateEnum
CREATE TYPE "StreakType" AS ENUM ('COWBOY_HAT', 'GUN', 'HORSE');
-- AlterTable
ALTER TABLE "Streak" ADD COLUMN "type" "StreakType" NOT NULL DEFAULT 'COWBOY_HAT';
-- AlterTable
ALTER TABLE "users" ADD COLUMN "gunStreak" INTEGER,
ADD COLUMN "horseStreak" INTEGER;
-- CreateIndex
CREATE INDEX "Streak_type_idx" ON "Streak"("type");
-- CreateIndex
CREATE INDEX "users_streak_idx" ON "users"("streak");
-- CreateIndex
CREATE INDEX "users_gunStreak_idx" ON "users"("gunStreak");
-- CreateIndex
CREATE INDEX "users_horseStreak_idx" ON "users"("horseStreak");
-- DropIndex
DROP INDEX "Streak.startedAt_userId_unique";
-- CreateIndex
CREATE UNIQUE INDEX "Streak_startedAt_userId_type_key" ON "Streak"("startedAt", "userId", "type");

View File

@ -74,6 +74,8 @@ model User {
slashtagId String? @unique(map: "users.slashtagId_unique") slashtagId String? @unique(map: "users.slashtagId_unique")
noteCowboyHat Boolean @default(true) noteCowboyHat Boolean @default(true)
streak Int? streak Int?
gunStreak Int?
horseStreak Int?
subs String[] subs String[]
hideCowboyHat Boolean @default(false) hideCowboyHat Boolean @default(false)
Bookmarks Bookmark[] Bookmarks Bookmark[]
@ -138,6 +140,9 @@ model User {
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@@index([inviteId], map: "users.inviteId_index") @@index([inviteId], map: "users.inviteId_index")
@@index([streak])
@@index([gunStreak])
@@index([horseStreak])
@@map("users") @@map("users")
} }
@ -300,17 +305,25 @@ model Arc {
@@index([toId, fromId]) @@index([toId, fromId])
} }
model Streak { enum StreakType {
id Int @id @default(autoincrement()) COWBOY_HAT
createdAt DateTime @default(now()) @map("created_at") GUN
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") HORSE
startedAt DateTime @db.Date }
endedAt DateTime? @db.Date
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([startedAt, userId], map: "Streak.startedAt_userId_unique") model Streak {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
startedAt DateTime @db.Date
endedAt DateTime? @db.Date
userId Int
type StreakType @default(COWBOY_HAT)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([startedAt, userId, type])
@@index([userId], map: "Streak.userId_index") @@index([userId], map: "Streak.userId_index")
@@index([type])
} }
model NostrRelay { model NostrRelay {

View File

@ -32,6 +32,8 @@ query TopCowboys($cursor: String) {
name name
optional { optional {
streak streak
gunStreak
horseStreak
} }
} }
cursor cursor

View File

@ -220,6 +220,10 @@ $zindex-sticky: 900;
scroll-margin-top: 60px; scroll-margin-top: 60px;
} }
.ms-xs {
margin-left: 0.125rem;
}
.text-monospace { .text-monospace {
font-family: monospace; font-family: monospace;
} }

3
svgs/holster.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="800" height="800" viewBox="0 0 800 800" xmlns="http://www.w3.org/2000/svg">
<path d="M52.0625 28.125L52.0625 228.989C63.5 229.611 128 210.5 219.464 210.5C245.62 205.513 309.5 188.311 339 161.286C365 142.5 398.509 137.5 424.75 137.5C472.566 137.5 496.897 150.534 507.658 153.898L517.838 157.077L514.06 283.869C582.583 280.122 653.807 273.45 732.062 263.809C722.568 262.503 715.254 256.038 710.562 249.002C705.309 256.88 696.773 264.062 685.562 264.062C674.351 264.062 665.815 256.88 660.562 249.002C655.309 256.88 646.773 264.062 635.562 264.062C624.351 264.062 615.815 256.88 610.562 249.002C605.309 256.88 596.773 264.062 585.562 264.062C572.304 264.062 562.77 254.022 558.005 244.659C553.239 235.297 550.892 224.859 549.289 214.691C547.817 205.355 547.128 196.266 546.792 189.062H546.5V179.044C546.485 177.837 546.485 176.63 546.5 175.423V28.125H52.0625ZM574.625 39.0625V160.938H596.5V39.0625H574.625ZM624.625 39.0625V160.938H646.5V39.0625H624.625ZM674.625 39.0625V160.938H696.5V39.0625H674.625ZM724.625 39.0625V160.938H746.5V39.0625H724.625ZM410.556 160.709C388.928 161.025 367.119 167.263 344.492 185.866C300.964 221.647 259.966 228.53 219.464 229.611C180.081 230.663 125.078 236.216 75.5 249.002C75.5 249.002 78.5 261.673 84 278.5C90.3739 298 100.755 321.903 117.286 347.8C150.348 399.592 205.597 455.2 266.595 488.589L272.717 491.941L281.759 552.372C290.552 553.019 299.197 553.53 307.73 553.9L298.355 481.516C202.5 425.5 138.217 347.8 110.77 267.87C121.291 265.058 129.317 262.898 138.217 261.673C214.873 252.073 281.861 268.972 374.085 196.305C384.699 187.941 396.686 184.536 408.933 183.927C410.466 183.851 412 183.817 413.535 183.825C424.294 183.872 435.145 185.85 445.347 188.311C460.649 192.003 475.636 196.842 488.419 200.07L489.094 177.484C469.003 170.891 447.327 163.344 424.75 161.286C421.468 160.983 418.177 160.795 414.881 160.724C413.44 160.693 411.998 160.689 410.556 160.709ZM575.006 189.062C575.325 195.308 575.883 202.764 577.072 210.309C578.425 218.891 580.681 227.203 583.072 231.903C585.464 236.603 585.645 235.938 585.562 235.938C585.479 235.938 585.661 236.603 588.053 231.903C590.443 227.203 592.7 218.891 594.053 210.309C595.24 202.763 595.8 195.308 596.118 189.062H575.006ZM625.006 189.062C625.325 195.308 625.883 202.764 627.072 210.309C628.425 218.891 630.681 227.203 633.072 231.903C635.464 236.603 635.645 235.938 635.562 235.938C635.479 235.938 635.661 236.603 638.053 231.903C640.443 227.203 642.7 218.891 644.053 210.309C645.24 202.763 645.8 195.308 646.118 189.062H625.006ZM675.006 189.062C675.325 195.308 675.883 202.764 677.072 210.309C678.425 218.891 680.681 227.203 683.072 231.903C685.464 236.603 685.645 235.938 685.562 235.938C685.479 235.938 685.661 236.603 688.053 231.903C690.443 227.203 692.7 218.891 694.053 210.309C695.24 202.763 695.8 195.308 696.118 189.062H675.006ZM725.006 189.062C725.325 195.308 725.883 202.764 727.072 210.309C728.425 218.891 730.681 227.203 733.072 231.903C735.464 236.603 735.645 235.938 735.562 235.938C735.479 235.938 735.661 236.603 738.053 231.903C740.443 227.203 742.7 218.891 744.053 210.309C745.24 202.763 745.8 195.308 746.118 189.062H725.006ZM414.331 212.003C405.085 211.847 397.431 213.713 391.492 218.394C294.833 294.555 217.144 281.841 147.3 289.011C173.316 351.289 235.939 416.139 318.064 460.349L324.417 463.77L336.185 554.62C383.072 554.92 428.006 550.748 478.231 541.792L487.56 228.989C470.883 225.484 454.264 219.395 438.752 215.653C429.681 213.466 421.524 212.122 414.331 212.003ZM502.888 565.622C416.181 582.627 344.089 586.542 258.559 578.477L276.012 678.664C359.869 684.164 420.449 682.395 500.019 665.997L502.888 565.622ZM473.527 699.68C432.811 706.469 395.585 709.394 356.261 709.603L362.797 760.058C382.492 764.899 401.156 765.43 417.431 763.034C448.366 758.481 468.656 742.269 472.566 731.875L473.527 699.68ZM305.125 708.499L309.285 736.303C316.872 741.321 324.825 745.76 333.078 749.584L327.863 709.319C320.424 709.131 312.831 708.845 305.125 708.499Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

18
svgs/horse.svg Normal file
View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg"
width="223" height="223" viewBox="0 0 222.03 222.03">
<path d="M47.048,49.264c0,0-6.253,9.383-14.662,10.361c0,0-0.979,9.966-11.142,4.498c-2.71-1.46-3.721-7.625-0.981-11.535
l2.154-3.135c0,0,5.666-17.59,10.752-22.68c0,0,1.761-8.79,0-12.897c0,0,3.127,1.761,4.102,3.517c0,0-0.387-5.284-1.369-6.26
c0,0,9.582,5.875,10.17,9.2c0,0,19.354-5.677,35.189,9.582c0,0-6.059-1.563-8.207-0.78c0,0,19.349,10.95,22.872,23.847
c0,0-6.256-4.111-8.213-3.523c0,0,13.878,12.123,11.337,33.233c0,0,1.569,4.307,17.792,5.087c0,0,25.221,0.198,34.997,14.475
c0,0,16.226-1.173,28.744,14.074c0,0,11.143,19.357,24.441,16.805c0,0-13.099,6.064-19.357,0.597c0,0,2.352,10.361,36.362,17.401
c0,0-49.703,19.795-53.965-15.445c-0.662-5.58,1.566-23.265-10.361-19.943c0,0,5.479,18.383-20.528,35.784
c0,0-6.059,7.034,0.585,22.089c0,0,3.334,10.557-9.771,11.922c0,0-12.129,5.083-17.209,15.835c0,0-4.297,2.146-7.625,2.146
c0,0-2.344,8.6-6.842,7.234c0,0-12.51-1.17-7.82-6.265l1.761-2.731c0,0-6.059,1.561,0-6.448c0,0,2.932-3.718,14.08-3.522
c0,0,2.148-4.688,13.098-9.191c0,0,7.436-3.712-1.95-19.352c0,0-4.501-13.488-7.433-15.641c0,0,0.588-7.82-2.346-11.147
c0,0-19.943,11.526-51.817-16.811l-6.059,5.681c0,0-14.467-0.391-17.789-7.234c0,0-8.6,5.083-5.278,18.376
c0,0,0.78,4.498-0.195,6.649c0,0,6.646-0.78,5.866,8.021c0,0,0.975,10.745-3.91,8.009c0,0-4.303-2.347-4.69-8.795
c0,0-1.957-5.669-5.086-7.82c0,0-3.121-3.913,0.783-10.167c0,0,1.566-5.083,1.767-9.965c0,0,1.173-5.675,2.542-6.845
c0,0-13.687-2.353-14.079,24.624l2.349,0.786c0,0,6.839,12.117-2.154,16.615c0,0-3.13,1.377-4.492-3.522l-0.981-10.557
c0,0-4.69-8.802-1.174-16.231c0,0,5.078-6.053,6.647-16.225c0,0,0.393-8.012,13.884-5.875c0,0,10.749,3.523,19.157,0.198
c0,0-0.612-15.899,5.677-24.054C51.555,74.976,58.391,55.124,47.048,49.264z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

1
svgs/revolver.svg Normal file
View File

@ -0,0 +1 @@
<svg width="512" height="512" viewBox="0 75 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M481.14 125.357c-18.78 5.476-34.912 14.487-46.952 32.973h46.953v-32.973zm-188.915 50.01l-13.125.002-.116 35.74H491.47l-.343-35.74H292.225v-.003zm-29.125.002l-33.07.003-97.298.008c-16.018 27.973-16.89 57.78 1.04 94.07H262.8l.063-20.22H168.09a8 8 0 1 1 0-16h94.8v-22.68h-95.15a8 8 0 1 1 0-16h95.3l.06-19.18zm-161.377.01c-7.834 28.723-12.348 45.61-18.73 58.69-6.78 13.893-15.75 23.88-32.3 41.7C11.077 351.204 17.48 389.416 20.46 432.083c12.07 14.128 29.67 21.282 48.724 23.54 17.703 2.097 36.135-.286 50.816-4.597-.272-47.016 8.213-93.296 40.84-139.84l5.264-7.507 6.724 6.23c18.24 16.9 40.922 21.272 63.205 17.717 22.283-3.555 43.756-15.464 57.254-30.285 9.92-10.894 12.492-23.074 11.66-37.932h-26.115l-.084 26.04h-.695c-9.56 10.992-33.904 24.083-47.803 24.146-13.556.06-35.84-13.197-47.896-24.145H123.88l-2.253-4.266c-20.284-38.435-21.828-74.208-7.06-105.803h-12.844zm-74.88 2.47c7.33 23.547 19.127 43.547 34.825 60.796 2.733-3.822 4.952-7.508 6.945-11.593 2.33-4.772 4.44-10.37 6.715-17.44-.225-.142-.403-.248-.635-.394-7.68-4.854-17.46-11.227-27.117-17.58-10.508-6.916-13.477-8.943-20.734-13.79zm252.09 49.26l-.042 13.66v2.638h82.72V227.11h-82.676zM88.642 293.9c16.474 0 30 13.525 30 29.998 0 16.474-13.526 30-30 30-16.473 0-30-13.526-30-30 0-16.473 13.527-29.998 30-29.998zm0 15.998c-7.826 0-14 6.174-14 14 0 7.827 6.174 14 14 14 7.827 0 14-6.173 14-14 0-7.826-6.173-14-14-14zm-18.025 67.676a13 13 0 0 1 12.625 12.998 13 13 0 1 1-26 0 13 13 0 0 1 13.375-12.998z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

3
svgs/saddle.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -144,9 +144,19 @@ export async function paidActionPaid ({ data: { invoiceId, ...args }, models, ln
} }
await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd }) await paidActions[dbInvoice.actionType].onPaid?.({ invoice: dbInvoice }, { models, tx, lnd })
// any paid action is eligible for a cowboy hat streak
await tx.$executeRaw` await tx.$executeRaw`
INSERT INTO pgboss.job (name, data) INSERT INTO pgboss.job (name, data)
VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}))` VALUES ('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'COWBOY_HAT'))`
if (dbInvoice.invoiceForward) {
// only paid forwards are eligible for a gun streak
await tx.$executeRaw`
INSERT INTO pgboss.job (name, data)
VALUES
('checkStreak', jsonb_build_object('id', ${dbInvoice.userId}, 'type', 'GUN')),
('checkStreak', jsonb_build_object('id', ${dbInvoice.invoiceForward.withdrawl.userId}, 'type', 'HORSE'))`
}
return { return {
confirmedAt: new Date(lndInvoice.confirmed_at), confirmedAt: new Date(lndInvoice.confirmed_at),

View File

@ -1,124 +1,169 @@
import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush' import { notifyNewStreak, notifyStreakLost } from '@/lib/webPush'
import { Prisma } from '@prisma/client'
const STREAK_THRESHOLD = 100 const COWBOY_HAT_STREAK_THRESHOLD = 100
const GUN_STREAK_THRESHOLD = 1000
const HORSE_STREAK_THRESHOLD = 1000
export async function computeStreaks ({ models }) { export async function computeStreaks ({ models }) {
// get all eligible users in the last day // get all eligible users in the last day
// if the user doesn't have an active streak, add one // if the user doesn't have an active streak, add one
// if they have an active streak but didn't maintain it, end it // if they have an active streak but didn't maintain it, end it
const endingStreaks = await models.$queryRaw` for (const type of ['COWBOY_HAT', 'GUN', 'HORSE']) {
WITH day_streaks (id) AS ( const endingStreaks = await models.$queryRaw`
SELECT "userId" WITH day_streaks (id) AS (
FROM ${getStreakQuery(type)}
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent ), existing_streaks (id, started_at) AS (
FROM "ItemAct" SELECT "userId", "startedAt"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date FROM "Streak"
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID') WHERE "Streak"."endedAt" IS NULL
GROUP BY "userId") AND "type" = ${type}::"StreakType"
UNION ALL ), new_streaks (id) AS (
(SELECT "userId", sats as sats_spent SELECT day_streaks.id
FROM "Donation" FROM day_streaks
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
WHERE existing_streaks.id IS NULL
), ending_streaks (id) AS (
SELECT existing_streaks.id
FROM existing_streaks
LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
WHERE day_streaks.id IS NULL
), extending_streaks (id, started_at) AS (
SELECT existing_streaks.id, existing_streaks.started_at
FROM existing_streaks
JOIN day_streaks ON existing_streaks.id = day_streaks.id
),
-- a bunch of mutations
streak_insert AS (
INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at)
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, ${type}::"StreakType", now_utc(), now_utc()
FROM new_streaks
), user_update_new_streaks AS (
UPDATE users SET ${getStreakColumn(type)} = 1 FROM new_streaks WHERE new_streaks.id = users.id
), user_update_end_streaks AS (
UPDATE users SET ${getStreakColumn(type)} = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
), user_update_extend_streaks AS (
UPDATE users
SET ${getStreakColumn(type)} = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
FROM extending_streaks WHERE extending_streaks.id = users.id
) )
UNION ALL UPDATE "Streak"
(SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
FROM "SubAct" FROM ending_streaks
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL AND "type" = ${type}::"StreakType"
AND "type" = 'BILLING' RETURNING "Streak".*`
GROUP BY "userId")) spending
GROUP BY "userId"
HAVING sum(sats_spent) >= 100
), existing_streaks (id, started_at) AS (
SELECT "userId", "startedAt"
FROM "Streak"
WHERE "Streak"."endedAt" IS NULL
), new_streaks (id) AS (
SELECT day_streaks.id
FROM day_streaks
LEFT JOIN existing_streaks ON existing_streaks.id = day_streaks.id
WHERE existing_streaks.id IS NULL
), ending_streaks (id) AS (
SELECT existing_streaks.id
FROM existing_streaks
LEFT JOIN day_streaks ON existing_streaks.id = day_streaks.id
WHERE day_streaks.id IS NULL
), extending_streaks (id, started_at) AS (
SELECT existing_streaks.id, existing_streaks.started_at
FROM existing_streaks
JOIN day_streaks ON existing_streaks.id = day_streaks.id
),
-- a bunch of mutations
streak_insert AS (
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at)
SELECT id, (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, now_utc(), now_utc()
FROM new_streaks
), user_update_new_streaks AS (
UPDATE users SET streak = 1 FROM new_streaks WHERE new_streaks.id = users.id
), user_update_end_streaks AS (
UPDATE users SET streak = NULL FROM ending_streaks WHERE ending_streaks.id = users.id
), user_update_extend_streaks AS (
UPDATE users
SET streak = (now() AT TIME ZONE 'America/Chicago')::date - extending_streaks.started_at
FROM extending_streaks WHERE extending_streaks.id = users.id
)
UPDATE "Streak"
SET "endedAt" = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date, updated_at = now_utc()
FROM ending_streaks
WHERE ending_streaks.id = "Streak"."userId" AND "endedAt" IS NULL
RETURNING "Streak".id, ending_streaks."id" AS "userId"`
Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak))) Promise.allSettled(endingStreaks.map(streak => notifyStreakLost(streak.userId, streak)))
}
} }
export async function checkStreak ({ data: { id }, models }) { export async function checkStreak ({ data: { id, type = 'COWBOY_HAT' }, models }) {
// if user is actively streaking skip // if user is actively streaking skip
let streak = await models.streak.findFirst({ const user = await models.user.findUnique({
where: { where: {
userId: Number(id), id: Number(id)
endedAt: null
} }
}) })
if (streak) { console.log('checking streak', id, type, isStreakActive(type, user))
if (isStreakActive(type, user)) {
return return
} }
[streak] = await models.$queryRaw` const [streak] = await models.$queryRaw`
WITH streak_started (id) AS ( WITH streak_started (id) AS (
SELECT "userId" ${getStreakQuery(type, id)}
FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)}
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
GROUP BY "userId")
UNION ALL
(SELECT "userId", sats as sats_spent
FROM "Donation"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)}
)
UNION ALL
(SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent
FROM "SubAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= (now() AT TIME ZONE 'America/Chicago')::date
AND "userId" = ${Number(id)}
AND "type" = 'BILLING'
GROUP BY "userId")
) spending
GROUP BY "userId"
HAVING sum(sats_spent) >= ${STREAK_THRESHOLD}
), user_start_streak AS ( ), user_start_streak AS (
UPDATE users SET streak = 0 FROM streak_started WHERE streak_started.id = users.id UPDATE users SET ${getStreakColumn(type)} = 0 FROM streak_started WHERE streak_started.id = users.id
) )
INSERT INTO "Streak" ("userId", "startedAt", created_at, updated_at) INSERT INTO "Streak" ("userId", "startedAt", "type", created_at, updated_at)
SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, now_utc(), now_utc() SELECT id, (now() AT TIME ZONE 'America/Chicago')::date, ${type}::"StreakType", now_utc(), now_utc()
FROM streak_started FROM streak_started
RETURNING "Streak".id` RETURNING "Streak".*`
if (!streak) return if (!streak) return
// new streak started for user // new streak started for user
notifyNewStreak(id, streak) notifyNewStreak(id, streak)
} }
function getStreakQuery (type, userId) {
const dayFragment = userId
? Prisma.sql`(now() AT TIME ZONE 'America/Chicago')::date`
: Prisma.sql`(now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date`
if (type === 'GUN') {
return Prisma.sql`
SELECT "Invoice"."userId"
FROM "Invoice"
JOIN "InvoiceForward" ON "Invoice".id = "InvoiceForward"."invoiceId"
WHERE ("Invoice"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
AND "Invoice"."actionState" = 'PAID'
${userId ? Prisma.sql`AND "Invoice"."userId" = ${userId}` : Prisma.empty}
GROUP BY "Invoice"."userId"
HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${GUN_STREAK_THRESHOLD}`
}
if (type === 'HORSE') {
return Prisma.sql`
SELECT "Withdrawl"."userId"
FROM "Withdrawl"
JOIN "InvoiceForward" ON "Withdrawl".id = "InvoiceForward"."withdrawlId"
JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE ("Withdrawl"."created_at" AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
AND "Invoice"."actionState" = 'PAID'
${userId ? Prisma.sql`AND "Withdrawl"."userId" = ${userId}` : Prisma.empty}
GROUP BY "Withdrawl"."userId"
HAVING sum(floor("Invoice"."msatsReceived"/1000)) >= ${HORSE_STREAK_THRESHOLD}`
}
return Prisma.sql`
SELECT "userId"
FROM
((SELECT "userId", floor(sum("ItemAct".msats)/1000) as sats_spent
FROM "ItemAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
AND ("ItemAct"."invoiceActionState" IS NULL OR "ItemAct"."invoiceActionState" = 'PAID')
${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty}
GROUP BY "userId")
UNION ALL
(SELECT "userId", sats as sats_spent
FROM "Donation"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty}
)
UNION ALL
(SELECT "userId", floor(sum("SubAct".msats)/1000) as sats_spent
FROM "SubAct"
WHERE (created_at AT TIME ZONE 'UTC' AT TIME ZONE 'America/Chicago')::date >= ${dayFragment}
${userId ? Prisma.sql`AND "userId" = ${userId}` : Prisma.empty}
AND "type" = 'BILLING'
GROUP BY "userId")) spending
GROUP BY "userId"
HAVING sum(sats_spent) >= ${COWBOY_HAT_STREAK_THRESHOLD}`
}
function isStreakActive (type, user) {
if (type === 'GUN') {
return typeof user.gunStreak === 'number'
}
if (type === 'HORSE') {
return typeof user.horseStreak === 'number'
}
return typeof user.streak === 'number'
}
function getStreakColumn (type) {
if (type === 'GUN') {
return Prisma.sql`"gunStreak"`
}
if (type === 'HORSE') {
return Prisma.sql`"horseStreak"`
}
return Prisma.sql`"streak"`
}

View File

@ -177,6 +177,8 @@ const THIS_DAY = gql`
id id
optional { optional {
streak streak
gunStreak
horseStreak
} }
} }
ncomments(when: "custom", from: $from, to: $to) ncomments(when: "custom", from: $from, to: $to)

View File

@ -34,7 +34,10 @@ function subscribeForever (subscribe) {
} }
if (sub.then) { if (sub.then) {
// sub is promise // sub is promise
sub.then(sub => sub.on('error', reject)) sub.then(resolved => {
sub = resolved
sub.on('error', reject)
})
} else { } else {
sub.on('error', reject) sub.on('error', reject)
} }