diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index d9405b99..5a113809 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -181,6 +181,15 @@ export default { GROUP BY "userId", created_at` ) } + + if (meFull.noteCowboyHat) { + queries.push( + `SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type + FROM "Streak" + WHERE "userId" = $1 + AND updated_at <= $2` + ) + } } // we do all this crazy subquery stuff to make 'reward' islands @@ -227,6 +236,17 @@ export default { JobChanged: { item: async (n, args, { models }) => getItem(n, { id: n.id }, { models }) }, + Streak: { + days: async (n, args, { models }) => { + const res = await models.$queryRaw` + SELECT "endedAt" - "startedAt" AS days + FROM "Streak" + WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL + ` + + return res.length ? res[0].days : null + } + }, Earn: { sources: async (n, args, { me, models }) => { const [sources] = await models.$queryRaw(` diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 4f92d4b7..941a789b 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -304,6 +304,21 @@ export default { } } + if (user.noteCowboyHat) { + const streak = await models.streak.findFirst({ + where: { + userId: me.id, + updatedAt: { + gt: lastChecked + } + } + }) + + if (streak) { + return true + } + } + return false }, searchUsers: async (parent, { q, limit, similarity }, { models }) => { @@ -475,6 +490,15 @@ export default { } }) }, + streak: async (user, args, { models }) => { + const res = await models.$queryRaw` + SELECT (now_utc() at time zone 'America/Chicago')::date - "startedAt" AS days + FROM "Streak" + WHERE "userId" = ${user.id} AND "endedAt" IS NULL + ` + + return res.length ? res[0].days : null + }, stacked: async (user, { when }, { models }) => { if (user.stacked) { return user.stacked diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 9d4d5c12..68b90926 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -38,6 +38,12 @@ export default gql` tips: Int! } + type Streak { + sortTime: String! + days: Int + id: ID! + } + type Earn { earnedSats: Int! sortTime: String! @@ -56,6 +62,7 @@ export default gql` union Notification = Reply | Votification | Mention | Invitification | Earn | JobChanged | InvoicePaid | Referral + | Streak type Notifications { lastChecked: String diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index e0604234..9cb4cea6 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -21,7 +21,7 @@ export default gql` setName(name: String!): 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!, + noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!, wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!]): User setPhoto(photoId: ID!): Int! upsertBio(bio: String!): User! @@ -58,6 +58,7 @@ export default gql` bio: Item bioId: Int photoId: Int + streak: Int sats: Int! upvotePopover: Boolean! tipPopover: Boolean! @@ -68,6 +69,7 @@ export default gql` noteDeposits: Boolean! noteInvites: Boolean! noteJobIndicator: Boolean! + noteCowboyHat: Boolean! hideInvoiceDesc: Boolean! hideFromTopUsers: Boolean! wildWestMode: Boolean! diff --git a/components/comment.js b/components/comment.js index 550c35a6..222c6993 100644 --- a/components/comment.js +++ b/components/comment.js @@ -23,6 +23,7 @@ import { Badge } from 'react-bootstrap' import { abbrNum } from '../lib/format' import Share from './share' import { DeleteDropdown } from './delete' +import CowboyHat from './cowboy-hat' function Parent ({ item, rootText }) { const ParentFrag = () => ( @@ -135,7 +136,10 @@ export default function Comment ({ \ - @{item.user.name}{op && ' OP'} + + @{item.user.name} + {op && OP} + diff --git a/components/cowboy-hat.js b/components/cowboy-hat.js new file mode 100644 index 00000000..d5ee37a1 --- /dev/null +++ b/components/cowboy-hat.js @@ -0,0 +1,36 @@ +import { Badge, OverlayTrigger, Tooltip } from 'react-bootstrap' +import CowboyHatIcon from '../svgs/cowboy.svg' + +export default function CowboyHat ({ streak, badge, className = 'ml-1', height = 16, width = 16 }) { + if (!streak) { + return null + } + + return ( + + {badge + ? ( + + + {streak} + ) + : } + + ) +} + +function HatTooltip ({ children, overlayText, placement }) { + return ( + + {overlayText || '1 sat'} + + } + trigger={['hover', 'focus']} + > + {children} + + ) +} diff --git a/components/header.js b/components/header.js index ff965727..56a96be8 100644 --- a/components/header.js +++ b/components/header.js @@ -15,6 +15,7 @@ import { abbrNum } from '../lib/format' import NoteIcon from '../svgs/notification-4-fill.svg' import { useQuery, gql } from '@apollo/client' import LightningIcon from '../svgs/bolt.svg' +import CowboyHat from './cowboy-hat' function WalletSummary ({ me }) { if (!me) return null @@ -72,7 +73,9 @@ export default function Header ({ sub }) { - e.preventDefault()}>{`@${me?.name}`} + e.preventDefault()}> + {`@${me?.name}`} + } alignRight > diff --git a/components/item-job.js b/components/item-job.js index 837f9743..4b8614a2 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -7,6 +7,7 @@ import Link from 'next/link' import { timeSince } from '../lib/time' import EmailIcon from '../svgs/mail-open-line.svg' import Share from './share' +import CowboyHat from './cowboy-hat' export default function ItemJob ({ item, toc, rank, children }) { const isEmail = Yup.string().email().isValidSync(item.url) @@ -52,7 +53,9 @@ export default function ItemJob ({ item, toc, rank, children }) { \ - @{item.user.name} + + @{item.user.name} + diff --git a/components/item.js b/components/item.js index ffc30b76..d7a99f9b 100644 --- a/components/item.js +++ b/components/item.js @@ -19,6 +19,7 @@ import Flag from '../svgs/flag-fill.svg' import Share from './share' import { abbrNum } from '../lib/format' import { DeleteDropdown } from './delete' +import CowboyHat from './cowboy-hat' export function SearchTitle ({ title }) { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { @@ -115,7 +116,7 @@ export default function Item ({ item, rank, showFwdUser, toc, children }) { \ - @{item.user.name} + @{item.user.name} diff --git a/components/notifications.js b/components/notifications.js index 0b8e1df0..d600d74d 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -12,6 +12,8 @@ import Link from 'next/link' import Check from '../svgs/check-double-line.svg' import HandCoin from '../svgs/hand-coin-fill.svg' import { COMMENT_DEPTH_LIMIT } from '../lib/constants' +import CowboyHatIcon from '../svgs/cowboy.svg' +import BaldIcon from '../svgs/bald.svg' // TODO: oh man, this is a mess ... each notification type should just be a component ... function Notification ({ n }) { @@ -20,7 +22,7 @@ function Notification ({ n }) {
{ - if (n.__typename === 'Earn' || n.__typename === 'Referral') { + if (n.__typename === 'Earn' || n.__typename === 'Referral' || n.__typename === 'Streak') { return } @@ -103,35 +105,76 @@ function Notification ({ n }) { {n.earnedSats} sats were deposited in your account {timeSince(new Date(n.sortTime))}
) - : ( - <> - {n.__typename === 'Votification' && - - your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} - } - {n.__typename === 'Mention' && - - you were mentioned in - } - {n.__typename === 'JobChanged' && - - {n.item.status === 'ACTIVE' - ? 'your job is active again' - : (n.item.status === 'NOSATS' - ? 'your job promotion ran out of sats' - : 'your job has been stopped')} - } -
- {n.item.isJob - ? - : n.item.title - ? - : ( -
- -
)} -
- )} + : n.__typename === 'Streak' + ? + : ( + <> + {n.__typename === 'Votification' && + + your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} + } + {n.__typename === 'Mention' && + + you were mentioned in + } + {n.__typename === 'JobChanged' && + + {n.item.status === 'ACTIVE' + ? 'your job is active again' + : (n.item.status === 'NOSATS' + ? 'your job promotion ran out of sats' + : 'your job has been stopped')} + } +
+ {n.item.isJob + ? + : n.item.title + ? + : ( +
+ +
)} +
+ )} + + ) +} + +function Streak ({ n }) { + function blurb (n) { + const index = Number(n.id) % 6 + 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.', + '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.', + "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 hat isn't just a fashion statement. It's your last defense against the unforgiving elements of the Wild West. Hang onto it tight.", + "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.' + ] + + 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.', + "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.' + ] + + if (n.days) { + return `After ${n.days} days, ` + LOST_BLURBS[index] + } + + return FOUND_BLURBS[index] + } + + return ( +
+
{n.days ? : }
+
+ you {n.days ? 'lost your' : 'found a'} cowboy hat +
{blurb(n)}
+
) } diff --git a/components/user-header.js b/components/user-header.js index 8e2d6885..0d5eb728 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -14,6 +14,7 @@ import LightningIcon from '../svgs/bolt.svg' import ModalButton from './modal-button' import { encodeLNUrl } from '../lib/lnurl' import Avatar from './avatar' +import CowboyHat from './cowboy-hat' export default function UserHeader ({ user }) { const [editting, setEditting] = useState(false) @@ -132,7 +133,7 @@ export default function UserHeader ({ user }) { ) : (
-
@{user.name}
+
@{user.name}
{isMe && }
diff --git a/components/user-list.js b/components/user-list.js index 39301ca3..971f4ceb 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -1,6 +1,7 @@ import Link from 'next/link' import { Image } from 'react-bootstrap' import { abbrNum } from '../lib/format' +import CowboyHat from './cowboy-hat' import styles from './item.module.css' import userStyles from './user-header.module.css' @@ -18,8 +19,8 @@ export default function UserList ({ users }) {
- - @{user.name} + + @{user.name}
diff --git a/fragments/comments.js b/fragments/comments.js index c2b452cd..d598ed7f 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -9,6 +9,7 @@ export const COMMENT_FIELDS = gql` text user { name + streak id } sats @@ -29,6 +30,7 @@ export const COMMENT_FIELDS = gql` bountyPaidTo user { name + streak id } } diff --git a/fragments/invites.js b/fragments/invites.js index 11e8436a..68a03fb7 100644 --- a/fragments/invites.js +++ b/fragments/invites.js @@ -13,6 +13,7 @@ export const INVITE_FIELDS = gql` revoked user { name + streak id } poor diff --git a/fragments/items.js b/fragments/items.js index 90cc12d9..e203d13e 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -11,10 +11,12 @@ export const ITEM_FIELDS = gql` url user { name + streak id } fwdUser { name + streak id } sats @@ -51,6 +53,7 @@ export const ITEM_FIELDS = gql` } user { name + streak id } } diff --git a/fragments/notifications.js b/fragments/notifications.js index 58482a55..39da59eb 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -28,6 +28,11 @@ export const NOTIFICATIONS = gql` text } } + ... on Streak { + id + sortTime + days + } ... on Earn { sortTime earnedSats diff --git a/fragments/users.js b/fragments/users.js index a69c4354..eb07f739 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -7,6 +7,7 @@ export const ME = gql` me { id name + streak sats stacked freePosts @@ -24,6 +25,7 @@ export const ME = gql` noteDeposits noteInvites noteJobIndicator + noteCowboyHat hideInvoiceDesc hideFromTopUsers wildWestMode @@ -44,6 +46,7 @@ export const SETTINGS_FIELDS = gql` noteDeposits noteInvites noteJobIndicator + noteCowboyHat hideInvoiceDesc hideFromTopUsers nostrPubkey @@ -72,12 +75,12 @@ gql` ${SETTINGS_FIELDS} 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!, + $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!, $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!]) { 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, + noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers, wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays) { ...SettingsFields } @@ -103,6 +106,7 @@ gql` query searchUsers($q: String!, $limit: Int, $similarity: Float) { searchUsers(q: $q, limit: $limit, similarity: $similarity) { name + streak photoId stacked spent @@ -117,6 +121,7 @@ export const USER_FIELDS = gql` id createdAt name + streak nitems ncomments stacked @@ -133,6 +138,7 @@ export const TOP_USERS = gql` topUsers(cursor: $cursor, when: $when, sort: $sort) { users { name + streak photoId stacked(when: $when) spent(when: $when) diff --git a/pages/settings.js b/pages/settings.js index d43a9a64..dcc4b41a 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -102,6 +102,7 @@ export default function Settings ({ data: { settings } }) { noteDeposits: settings?.noteDeposits, noteInvites: settings?.noteInvites, noteJobIndicator: settings?.noteJobIndicator, + noteCowboyHat: settings?.noteCowboyHat, hideInvoiceDesc: settings?.hideInvoiceDesc, hideFromTopUsers: settings?.hideFromTopUsers, wildWestMode: settings?.wildWestMode, @@ -216,6 +217,11 @@ export default function Settings ({ data: { settings } }) { +
privacy
+ + \ No newline at end of file diff --git a/svgs/cowboy.svg b/svgs/cowboy.svg new file mode 100644 index 00000000..a4b2da86 --- /dev/null +++ b/svgs/cowboy.svg @@ -0,0 +1,21 @@ + + + + diff --git a/worker/index.js b/worker/index.js index bed58ff2..73322379 100644 --- a/worker/index.js +++ b/worker/index.js @@ -10,6 +10,7 @@ const { earn } = require('./earn') const { ApolloClient, HttpLink, InMemoryCache } = require('@apollo/client') const { indexItem, indexAllItems } = require('./search') const { timestampItem } = require('./ots') +const { computeStreaks } = require('./streak') const fetch = require('cross-fetch') async function work () { @@ -47,6 +48,7 @@ async function work () { await boss.work('indexAllItems', indexAllItems(args)) await boss.work('auction', auction(args)) await boss.work('earn', earn(args)) + await boss.work('streak', computeStreaks(args)) console.log('working jobs') } diff --git a/worker/streak.js b/worker/streak.js new file mode 100644 index 00000000..9c4aebf4 --- /dev/null +++ b/worker/streak.js @@ -0,0 +1,51 @@ +function computeStreaks ({ models }) { + return async function () { + console.log('computing streaks') + + // get all eligible users in the last day + // if the user doesn't have an active streak, add one + // if they have an active streak but didn't maintain it, end it + await models.$executeRaw( + `WITH day_streaks (id) AS ( + 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 = (now() AT TIME ZONE 'America/Chicago' - interval '1 day')::date + 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' - interval '1 day')::date + )) spending + GROUP BY "userId" + HAVING sum(sats_spent) >= 100 + ), existing_streaks (id) AS ( + SELECT "userId" + 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 + ), 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 + ) + 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"`) + + console.log('done computing streaks') + } +} + +module.exports = { computeStreaks }