Merge branch 'master' into 266-zaps-without-account

This commit is contained in:
Keyan 2023-08-07 15:10:20 -05:00 committed by GitHub
commit 3d0bb4b32c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 934 additions and 1001 deletions

View File

@ -0,0 +1,6 @@
Resources:
AWSEBAutoScalingGroup:
Type: "AWS::AutoScaling::AutoScalingGroup"
Properties:
HealthCheckType: ELB
HealthCheckGracePeriod: 300

View File

@ -4,4 +4,4 @@ echo primsa migrate
npm run migrate npm run migrate
echo build with npm echo build with npm
npm run build sudo -E -u webapp npm run build

View File

@ -4,4 +4,4 @@ echo primsa migrate
npm run migrate npm run migrate
echo build with npm echo build with npm
npm run build sudo -E -u webapp npm run build

View File

@ -28,9 +28,9 @@ http {
listen 8008 default_server; listen 8008 default_server;
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
client_header_timeout 60; client_header_timeout 90;
client_body_timeout 60; client_body_timeout 90;
keepalive_timeout 60; keepalive_timeout 90;
gzip on; gzip on;
gzip_comp_level 4; gzip_comp_level 4;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:16.16.0-bullseye FROM node:18.17.0-bullseye
ENV NODE_ENV=development ENV NODE_ENV=development

View File

@ -12,6 +12,8 @@ export default {
} }
}) })
if (!lastReward) return { total: 0, sources: [] }
const [result] = await models.$queryRaw` const [result] = await models.$queryRaw`
SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array( SELECT coalesce(FLOOR(sum(sats)), 0) as total, json_build_array(
json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)), json_build_object('name', 'donations', 'value', coalesce(FLOOR(sum(sats) FILTER(WHERE type = 'DONATION')), 0)),

View File

@ -35,17 +35,17 @@ async function serialize (models, ...calls) {
if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) { if (error.message.includes('SN_REVOKED_OR_EXHAUSTED')) {
bail(new Error('faucet has been revoked or is exhausted')) bail(new Error('faucet has been revoked or is exhausted'))
} }
if (error.message.includes('23514')) {
bail(new Error('constraint failure'))
}
if (error.message.includes('SN_INV_PENDING_LIMIT')) { if (error.message.includes('SN_INV_PENDING_LIMIT')) {
bail(new Error('too many pending invoices')) bail(new Error('too many pending invoices'))
} }
if (error.message.includes('SN_INV_EXCEED_BALANCE')) { if (error.message.includes('SN_INV_EXCEED_BALANCE')) {
bail(new Error('pending invoices must not cause balance to exceed 1m sats')) bail(new Error('pending invoices must not cause balance to exceed 1m sats'))
} }
if (error.message.includes('40001')) { if (error.message.includes('40001') || error.code === 'P2034') {
throw new Error('wallet balance serialization failure - retry again') throw new Error('wallet balance serialization failure - try again')
}
if (error.message.includes('23514') || ['P2002', 'P2003', 'P2004'].includes(error.code)) {
bail(new Error('constraint failure'))
} }
bail(error) bail(error)
} }

View File

@ -172,7 +172,7 @@ export default {
} }
switch (f.type) { switch (f.type) {
case 'withdrawal': case 'withdrawal':
f.msats = (-1 * f.msats) - f.msatsFee f.msats = (-1 * Number(f.msats)) - Number(f.msatsFee)
break break
case 'spent': case 'spent':
f.msats *= -1 f.msats *= -1

View File

@ -38,14 +38,14 @@ export default async function getSSRApolloClient ({ req, res, me = null }) {
assumeImmutableResults: true, assumeImmutableResults: true,
defaultOptions: { defaultOptions: {
watchQuery: { watchQuery: {
fetchPolicy: 'cache-only', fetchPolicy: 'no-cache',
nextFetchPolicy: 'cache-only', nextFetchPolicy: 'no-cache',
canonizeResults: true, canonizeResults: true,
ssr: true ssr: true
}, },
query: { query: {
fetchPolicy: 'cache-first', fetchPolicy: 'no-cache',
nextFetchPolicy: 'cache-only', nextFetchPolicy: 'no-cache',
canonizeResults: true, canonizeResults: true,
ssr: true ssr: true
} }

View File

@ -89,7 +89,7 @@ export function WhenAreaChart ({ data }) {
tick={{ fill: 'var(--theme-grey)' }} tick={{ fill: 'var(--theme-grey)' }}
/> />
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)} <Area key={v} type='monotone' dataKey={v} name={v} stackId='1' stroke={COLORS[i]} fill={COLORS[i]} />)}
@ -124,7 +124,7 @@ export function WhenLineChart ({ data }) {
tick={{ fill: 'var(--theme-grey)' }} tick={{ fill: 'var(--theme-grey)' }}
/> />
<YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) => {Object.keys(data[0]).filter(v => v !== 'time' && v !== '__typename').map((v, i) =>
<Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)} <Line key={v} type='monotone' dataKey={v} name={v} stroke={COLORS[i]} fill={COLORS[i]} />)}
@ -160,7 +160,7 @@ export function WhenComposedChart ({ data, lineNames, areaNames, barNames }) {
/> />
<YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis yAxisId='left' orientation='left' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} /> <YAxis yAxisId='right' orientation='right' allowDecimals={false} stroke='var(--theme-grey)' tickFormatter={abbrNum} tick={{ fill: 'var(--theme-grey)' }} />
<Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--theme-color)', backgroundColor: 'var(--theme-body)' }} /> <Tooltip labelFormatter={dateFormatter(when)} contentStyle={{ color: 'var(--bs-body-color)', backgroundColor: 'var(--bs-body-bg)' }} />
<Legend /> <Legend />
{barNames?.map((v, i) => {barNames?.map((v, i) =>
<Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--bs-info)' fill='var(--bs-info)' />)} <Bar yAxisId='right' key={v} type='monotone' dataKey={v} name={v} stroke='var(--bs-info)' fill='var(--bs-info)' />)}

View File

@ -114,15 +114,19 @@ export default function Comment ({
if (Number(router.query.commentId) === Number(item.id)) { if (Number(router.query.commentId) === Number(item.id)) {
// HACK wait for other comments to collapse if they're collapsed // HACK wait for other comments to collapse if they're collapsed
setTimeout(() => { setTimeout(() => {
ref.current.scrollIntoView() ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
ref.current.classList.add('flash-it') ref.current.classList.add('outline-it')
router.replace({
pathname: router.pathname,
query: { id: router.query.id }
}, undefined, { scroll: false })
}, 20) }, 20)
} }
}, [item]) }, [item.id, router.query.commentId])
useEffect(() => {
if (router.query.commentsViewedAt &&
me?.id !== item.user?.id &&
new Date(item.createdAt).getTime() > router.query.commentsViewedAt) {
ref.current.classList.add('outline-new-comment')
}
}, [item.id])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const op = root.user.name === item.user.name const op = root.user.name === item.user.name
@ -131,6 +135,8 @@ export default function Comment ({
return ( return (
<div <div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.meDontLike {item.meDontLike

View File

@ -73,7 +73,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
router.push({ router.push({
pathname: router.pathname, pathname: router.pathname,
query: { ...router.query, sort } query: { ...router.query, sort }
}, router.asPath, { scroll: false }) }, undefined, { scroll: false })
}} }}
/> />
: null} : null}

View File

@ -1,5 +1,54 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getTheme, listenForThemeChange, setTheme } from '../public/dark'
const handleThemeChange = (dark) => {
const root = window.document.documentElement
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
}
const STORAGE_KEY = 'darkMode'
const PREFER_DARK_QUERY = '(prefers-color-scheme: dark)'
const getTheme = () => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
const supportsColorSchemeQuery = mql.media === PREFER_DARK_QUERY
let localStorageTheme = null
try {
localStorageTheme = window.localStorage.getItem(STORAGE_KEY)
} catch (err) {}
const localStorageExists = localStorageTheme !== null
if (localStorageExists) {
localStorageTheme = JSON.parse(localStorageTheme)
}
if (localStorageExists) {
return { user: true, dark: localStorageTheme }
} else if (supportsColorSchemeQuery) {
return { user: false, dark: mql.matches }
}
}
const setTheme = (dark) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(dark))
handleThemeChange(dark)
}
const listenForThemeChange = (onChange) => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
mql.onchange = mql => {
const { user, dark } = getTheme()
if (!user) {
handleThemeChange(dark)
onChange({ user, dark })
}
}
window.onstorage = e => {
if (e.key === STORAGE_KEY) {
const dark = JSON.parse(e.newValue)
setTheme(dark)
onChange({ user: true, dark })
}
}
}
export default function useDarkMode () { export default function useDarkMode () {
const [dark, setDark] = useState() const [dark, setDark] = useState()

View File

@ -5,7 +5,7 @@ import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
import { useMe } from './me' import { useMe } from './me'
import { ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants' import { SSR, ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants'
import { useEffect } from 'react' import { useEffect } from 'react'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
@ -48,7 +48,7 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
const query = parentId const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }` ? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }` : gql`{ itemRepetition }`
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const repetition = me ? data?.itemRepetition || 0 : 0 const repetition = me ? data?.itemRepetition || 0 : 0
const formik = useFormikContext() const formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0 const boost = Number(formik?.values?.boost) || 0

View File

@ -8,6 +8,8 @@
.receipt td { .receipt td {
padding: .25rem .1rem; padding: .25rem .1rem;
background-color: var(--theme-inputBg);
color: var(--bs-body-color);
} }
.receipt tfoot { .receipt tfoot {

View File

@ -1,6 +1,7 @@
import { gql, useQuery } from '@apollo/client' import { gql, useQuery } from '@apollo/client'
import Link from 'next/link' import Link from 'next/link'
import { RewardLine } from '../pages/rewards' import { RewardLine } from '../pages/rewards'
import { SSR } from '../lib/constants'
const REWARDS = gql` const REWARDS = gql`
{ {
@ -10,7 +11,7 @@ const REWARDS = gql`
}` }`
export default function Rewards () { export default function Rewards () {
const { data } = useQuery(REWARDS, { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(REWARDS, SSR ? { ssr: false } : { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
const total = data?.expectedRewards?.total const total = data?.expectedRewards?.total
return ( return (

View File

@ -11,13 +11,13 @@
} }
.contrastLink { .contrastLink {
color: var(--theme-color); color: var(--bs-body-color);
} }
.contrastLink:hover { .contrastLink:hover {
color: var(--theme-color); color: var(--bs-body-color);
} }
.contrastLink svg { .contrastLink svg {
fill: var(--theme-color); fill: var(--bs-body-color);
} }
.version { .version {

View File

@ -72,7 +72,7 @@ export function InputSkeleton ({ label, hint }) {
return ( return (
<BootstrapForm.Group> <BootstrapForm.Group>
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>} {label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
<div className='form-control clouds' /> <div className='form-control clouds' style={{ color: 'transparent' }}>.</div>
{hint && {hint &&
<BootstrapForm.Text> <BootstrapForm.Text>
{hint} {hint}
@ -431,7 +431,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
handleChange && handleChange(e.target.checked, helpers.setValue) handleChange && handleChange(e.target.checked, helpers.setValue)
}} }}
/> />
<BootstrapForm.Check.Label className={'d-flex' + (disabled ? ' text-muted' : '')}> <BootstrapForm.Check.Label className={'d-inline-flex flex-nowrap align-items-center' + (disabled ? ' text-muted' : '')}>
<div className='flex-grow-1'>{label}</div> <div className='flex-grow-1'>{label}</div>
{extra && {extra &&
<div className={styles.checkboxExtra}> <div className={styles.checkboxExtra}>

View File

@ -10,7 +10,7 @@ import Price from './price'
import { useMe } from './me' import { useMe } from './me'
import Head from 'next/head' import Head from 'next/head'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect } from 'react'
import { randInRange } from '../lib/rand' import { randInRange } from '../lib/rand'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg' import NoteIcon from '../svgs/notification-4-fill.svg'
@ -20,7 +20,7 @@ import CowboyHat from './cowboy-hat'
import { Select } from './form' import { Select } from './form'
import SearchIcon from '../svgs/search-line.svg' import SearchIcon from '../svgs/search-line.svg'
import BackArrow from '../svgs/arrow-left-line.svg' import BackArrow from '../svgs/arrow-left-line.svg'
import { SUBS } from '../lib/constants' import { SSR, SUBS } from '../lib/constants'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { HAS_NOTIFICATIONS } from '../fragments/notifications' import { HAS_NOTIFICATIONS } from '../fragments/notifications'
@ -31,21 +31,25 @@ function WalletSummary ({ me }) {
function Back () { function Back () {
const router = useRouter() const router = useRouter()
const [show, setShow] = useState()
useEffect(() => { return router.asPath !== '/' &&
setShow(typeof window !== 'undefined' && router.asPath !== '/' && <a
(typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack)) role='button' tabIndex='0' className='nav-link standalone p-0' onClick={() => {
}, [router.asPath]) if (typeof window.navigation === 'undefined' || window.navigation.canGoBack === undefined || window?.navigation.canGoBack) {
router.back()
if (show) { } else {
return <a role='button' tabIndex='0' className='nav-link standalone p-0' onClick={() => router.back()}><BackArrow className='theme me-1 me-md-2' width={22} height={22} /></a> router.push('/')
} }
return null }}
>
<BackArrow className='theme me-1 me-md-2' width={22} height={22} />
</a>
} }
function NotificationBell () { function NotificationBell () {
const { data } = useQuery(HAS_NOTIFICATIONS, { const { data } = useQuery(HAS_NOTIFICATIONS, SSR
? {}
: {
pollInterval: 30000, pollInterval: 30000,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })

View File

@ -38,7 +38,7 @@
background-color: var(--bs-primary); background-color: var(--bs-primary);
top: 3px; top: 3px;
right: 0px; right: 0px;
border: 1px solid var(--theme-body); border: 1px solid var(--bs-body-bg);
} }
.notification { .notification {
@ -47,7 +47,7 @@
background-color: var(--bs-danger); background-color: var(--bs-danger);
top: 1px; top: 1px;
right: 8px; right: 8px;
border: 1px solid var(--theme-body); border: 1px solid var(--bs-body-bg);
} }
.navbarNav { .navbarNav {

View File

@ -5,7 +5,7 @@ import Badge from 'react-bootstrap/Badge'
import Dropdown from 'react-bootstrap/Dropdown' import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown' import Countdown from './countdown'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import { newComments } from '../lib/new-comments' import { newComments, commentsViewedAt } from '../lib/new-comments'
import { timeSince } from '../lib/time' import { timeSince } from '../lib/time'
import CowboyHat from './cowboy-hat' import CowboyHat from './cowboy-hat'
import { DeleteDropdownItem } from './delete' import { DeleteDropdownItem } from './delete'
@ -48,9 +48,22 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
<span>{abbrNum(item.boost)} boost</span> <span>{abbrNum(item.boost)} boost</span>
<span> \ </span> <span> \ </span>
</>} </>}
<Link href={`/items/${item.id}`} title={`${item.commentSats} sats`} className='text-reset'> <Link
href={`/items/${item.id}`} onClick={(e) => {
const viewedAt = commentsViewedAt(item)
if (viewedAt) {
e.preventDefault()
router.push(
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`)
}
}} title={`${item.commentSats} sats`} className='text-reset position-relative'
>
{item.ncomments} {commentsText || 'comments'} {item.ncomments} {commentsText || 'comments'}
{hasNewComments && <>{' '}<Badge className={styles.newComment} bg={null}>new</Badge></>} {hasNewComments &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Link> </Link>
<span> \ </span> <span> \ </span>
<span> <span>

View File

@ -54,7 +54,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
@{item.user.name}<CowboyHat className='ms-1 fill-grey' user={item.user} height={12} width={12} /> @{item.user.name}<CowboyHat className='ms-1 fill-grey' user={item.user} height={12} width={12} />
</Link> </Link>
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset'> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))} {timeSince(new Date(item.createdAt))}
</Link> </Link>
</span> </span>

View File

@ -12,6 +12,8 @@ import Flag from '../svgs/flag-fill.svg'
import ImageIcon from '../svgs/image-fill.svg' import ImageIcon from '../svgs/image-fill.svg'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import ItemInfo from './item-info' import ItemInfo from './item-info'
import { commentsViewedAt } from '../lib/new-comments'
import { useRouter } from 'next/router'
export function SearchTitle ({ title }) { export function SearchTitle ({ title }) {
return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => { return reactStringReplace(title, /:high\[([^\]]+)\]/g, (match, i) => {
@ -21,6 +23,7 @@ export function SearchTitle ({ title }) {
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter()
const [pendingSats, setPendingSats] = useState(0) const [pendingSats, setPendingSats] = useState(0)
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
@ -39,7 +42,18 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
: item.meDontLike ? <Flag width={24} height={24} className={styles.dontLike} /> : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />} : item.meDontLike ? <Flag width={24} height={24} className={styles.dontLike} /> : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link href={`/items/${item.id}`} ref={titleRef} className={`${styles.title} text-reset me-2`}> <Link
href={`/items/${item.id}`}
onClick={(e) => {
const viewedAt = commentsViewedAt(item)
if (viewedAt) {
e.preventDefault()
router.push(
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`)
}
}} ref={titleRef} className={`${styles.title} text-reset me-2`}
>
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title} {item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ms-1' height={14} width={14} /></span>} {item.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ms-1' height={14} width={14} /></span>}
{item.bounty > 0 && {item.bounty > 0 &&

View File

@ -7,6 +7,15 @@
padding-bottom: .15rem; padding-bottom: .15rem;
} }
.notification {
position: absolute;
padding: 3px;
background-color: var(--bs-info);
top: -3px;
right: -4px;
border: 1px solid var(--bs-body-bg);
}
.icon { .icon {
display: inline-block; display: inline-block;
} }

View File

@ -8,19 +8,21 @@ import { CommentFlat } from './comment'
import { SUB_ITEMS } from '../fragments/subs' import { SUB_ITEMS } from '../fragments/subs'
import { LIMIT } from '../lib/cursor' import { LIMIT } from '../lib/cursor'
import ItemFull from './item-full' import ItemFull from './item-full'
import { useData } from './use-data'
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) { export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables }) const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
const Foooter = Footer || MoreFooter const Foooter = Footer || MoreFooter
const dat = useData(data, ssrData)
const { items, pins, cursor } = useMemo(() => { const { items, pins, cursor } = useMemo(() => {
if (!data && !ssrData) return {} if (!dat) return {}
if (destructureData) { if (destructureData) {
return destructureData(data || ssrData) return destructureData(dat)
} else { } else {
return data?.items || ssrData?.items return dat?.items
} }
}, [data, ssrData]) }, [dat])
const pinMap = useMemo(() => const pinMap = useMemo(() =>
pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins]) pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins])
@ -28,7 +30,7 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
const Skeleton = useCallback(() => const Skeleton = useCallback(() =>
<ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} />, [rank, items]) <ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} />, [rank, items])
if (!ssrData && !data) { if (!dat) {
return <Skeleton /> return <Skeleton />
} }

View File

@ -9,6 +9,7 @@ import Qr, { QrSkeleton } from './qr'
import styles from './lightning-auth.module.css' import styles from './lightning-auth.module.css'
import BackIcon from '../svgs/arrow-left-line.svg' import BackIcon from '../svgs/arrow-left-line.svg'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { SSR } from '../lib/constants'
function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) { function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
const query = gql` const query = gql`
@ -18,7 +19,7 @@ function QrAuth ({ k1, encodedUrl, slashtagUrl, callbackUrl }) {
k1 k1
} }
}` }`
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
useEffect(() => { useEffect(() => {
if (data?.lnAuth?.pubkey) { if (data?.lnAuth?.pubkey) {

View File

@ -85,10 +85,13 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
className={`mt-2 ${styles.providerButton}`} className={`mt-2 ${styles.providerButton}`}
key={provider.id} key={provider.id}
type={provider.id.toLowerCase()} type={provider.id.toLowerCase()}
onClick={() => router.push({ onClick={() => {
const { nodata, ...query } = router.query
router.push({
pathname: router.pathname, pathname: router.pathname,
query: { callbackUrl: router.query.callbackUrl, type: provider.name.toLowerCase() } query: { ...query, type: provider.name.toLowerCase() }
})} })
}}
text={`${text || 'Login'} with`} text={`${text || 'Login'} with`}
/> />
) )

View File

@ -1,13 +1,14 @@
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { ME } from '../fragments/users' import { ME } from '../fragments/users'
import { SSR } from '../lib/constants'
export const MeContext = React.createContext({ export const MeContext = React.createContext({
me: null me: null
}) })
export function MeProvider ({ me, children }) { export function MeProvider ({ me, children }) {
const { data } = useQuery(ME, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(ME, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const contextValue = { const contextValue = {
me: data?.me || me me: data?.me || me

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useApolloClient, useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import Item from './item' import Item from './item'
import ItemJob from './item-job' import ItemJob from './item-job'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications' import { NOTIFICATIONS } from '../fragments/notifications'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import Invite from './invite' import Invite from './invite'
import { ignoreClick } from '../lib/clicks' import { ignoreClick } from '../lib/clicks'
@ -20,29 +20,47 @@ import styles from './notifications.module.css'
import { useServiceWorker } from './serviceworker' import { useServiceWorker } from './serviceworker'
import { Checkbox, Form } from './form' import { Checkbox, Form } from './form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useData } from './use-data'
function Notification ({ n }) { function Notification ({ n, fresh }) {
switch (n.__typename) { const type = n.__typename
case 'Earn': return <EarnNotification n={n} />
case 'Invitification': return <Invitification n={n} /> return (
case 'InvoicePaid': return <InvoicePaid n={n} /> <NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
case 'Referral': return <Referral n={n} /> {
case 'Streak': return <Streak n={n} /> (type === 'Earn' && <EarnNotification n={n} />) ||
case 'Votification': return <Votification n={n} /> (type === 'Invitification' && <Invitification n={n} />) ||
case 'Mention': return <Mention n={n} /> (type === 'InvoicePaid' && <InvoicePaid n={n} />) ||
case 'JobChanged': return <JobChanged n={n} /> (type === 'Referral' && <Referral n={n} />) ||
case 'Reply': return <Reply n={n} /> (type === 'Streak' && <Streak n={n} />) ||
(type === 'Votification' && <Votification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />)
} }
console.error('__typename not supported:', n.__typename) </NotificationLayout>
return null )
} }
function NotificationLayout ({ children, href, as }) { function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter() const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
return ( return (
<div <div
className='clickToContext' className={
onClick={(e) => !ignoreClick(e) && router.push(href, as)} `clickToContext ${fresh ? styles.fresh : ''} ${router?.query?.nid === nid ? 'outline-it' : ''}`
}
onClick={async (e) => {
if (ignoreClick(e)) return
nid && await router.replace({
pathname: router.pathname,
query: {
...router.query,
nid
}
}, router.asPath, { ...router.options, shallow: true })
router.push(href, as)
}}
> >
{children} {children}
</div> </div>
@ -50,6 +68,14 @@ function NotificationLayout ({ children, href, as }) {
} }
const defaultOnClick = n => { const defaultOnClick = n => {
const type = n.__typename
if (type === 'Earn') return {}
if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'Referral') return { href: '/referrals/month' }
if (type === 'Streak') return {}
// Votification, Mention, JobChanged, Reply all have item
if (!n.item.title) { if (!n.item.title) {
const path = n.item.path.split('.') const path = n.item.path.split('.')
if (path.length > COMMENT_DEPTH_LIMIT + 1) { if (path.length > COMMENT_DEPTH_LIMIT + 1) {
@ -126,7 +152,7 @@ function EarnNotification ({ n }) {
<HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} /> <HandCoin className='align-self-center fill-boost mx-1' width={24} height={24} style={{ flex: '0 0 24px', transform: 'rotateY(180deg)' }} />
<div className='ms-2'> <div className='ms-2'>
<div className='fw-bold text-boost'> <div className='fw-bold text-boost'>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small> you stacked {n.earnedSats} sats in rewards<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</div> </div>
{n.sources && {n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}> <div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
@ -145,7 +171,7 @@ function EarnNotification ({ n }) {
function Invitification ({ n }) { function Invitification ({ n }) {
return ( return (
<NotificationLayout href='/invites'> <>
<small className='fw-bold text-secondary ms-2'> <small className='fw-bold text-secondary ms-2'>
your invite has been redeemed by {n.invite.invitees.length} stackers your invite has been redeemed by {n.invite.invitees.length} stackers
</small> </small>
@ -157,35 +183,31 @@ function Invitification ({ n }) {
} }
/> />
</div> </div>
</NotificationLayout> </>
) )
} }
function InvoicePaid ({ n }) { function InvoicePaid ({ n }) {
return ( return (
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
<div className='fw-bold text-info ms-2 py-1'> <div className='fw-bold text-info ms-2 py-1'>
<Check className='fill-info me-1' />{n.earnedSats} sats were deposited in your account <Check className='fill-info me-1' />{n.earnedSats} sats were deposited in your account
<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</div> </div>
</NotificationLayout>
) )
} }
function Referral ({ n }) { function Referral ({ n }) {
return ( return (
<NotificationLayout>
<small className='fw-bold text-secondary ms-2'> <small className='fw-bold text-secondary ms-2'>
someone joined via one of your <Link href='/referrals/month' className='text-reset'>referral links</Link> someone joined via one of your referral links
<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small> </small>
</NotificationLayout>
) )
} }
function Votification ({ n }) { function Votification ({ n }) {
return ( return (
<NotificationLayout {...defaultOnClick(n)}> <>
<small className='fw-bold text-success ms-2'> <small className='fw-bold text-success ms-2'>
your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`} your {n.item.title ? 'post' : 'reply'} {n.item.fwdUser ? 'forwarded' : 'stacked'} {n.earnedSats} sats{n.item.fwdUser && ` to @${n.item.fwdUser.name}`}
</small> </small>
@ -200,13 +222,13 @@ function Votification ({ n }) {
</div> </div>
)} )}
</div> </div>
</NotificationLayout> </>
) )
} }
function Mention ({ n }) { function Mention ({ n }) {
return ( return (
<NotificationLayout {...defaultOnClick(n)}> <>
<small className='fw-bold text-info ms-2'> <small className='fw-bold text-info ms-2'>
you were mentioned in you were mentioned in
</small> </small>
@ -220,13 +242,13 @@ function Mention ({ n }) {
</RootProvider> </RootProvider>
</div>)} </div>)}
</div> </div>
</NotificationLayout> </>
) )
} }
function JobChanged ({ n }) { function JobChanged ({ n }) {
return ( return (
<NotificationLayout {...defaultOnClick(n)}> <>
<small className={`fw-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ms-1`}> <small className={`fw-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ms-1`}>
{n.item.status === 'ACTIVE' {n.item.status === 'ACTIVE'
? 'your job is active again' ? 'your job is active again'
@ -235,13 +257,12 @@ function JobChanged ({ n }) {
: 'your job has been stopped')} : 'your job has been stopped')}
</small> </small>
<ItemJob item={n.item} /> <ItemJob item={n.item} />
</NotificationLayout> </>
) )
} }
function Reply ({ n }) { function Reply ({ n }) {
return ( return (
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
<div className='py-2'> <div className='py-2'>
{n.item.title {n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />
@ -253,11 +274,10 @@ function Reply ({ n }) {
</div> </div>
)} )}
</div> </div>
</NotificationLayout>
) )
} }
function NotificationAlert () { export function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false) const [showAlert, setShowAlert] = useState(false)
const [hasSubscription, setHasSubscription] = useState(false) const [hasSubscription, setHasSubscription] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -316,47 +336,39 @@ function NotificationAlert () {
) )
} }
const nid = n => n.__typename + n.id + n.sortTime
export default function Notifications ({ ssrData }) { export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS) const { data, fetchMore } = useQuery(NOTIFICATIONS)
const client = useApolloClient() const router = useRouter()
const dat = useData(data, ssrData)
const { notifications: { notifications, lastChecked, cursor } } = useMemo(() => {
return dat || { notifications: {} }
}, [dat])
useEffect(() => { useEffect(() => {
client.writeQuery({ if (lastChecked && !router?.query?.checkedAt) {
query: HAS_NOTIFICATIONS, router.replace({
data: { pathname: router.pathname,
hasNewNotes: false query: {
...router.query,
nodata: true, // make sure nodata is set so we don't fetch on back/forward
checkedAt: lastChecked
} }
}) }, router.asPath, { ...router.options, shallow: true })
}, [client]) }
}, [router, lastChecked])
const { notifications: { notifications, earn, lastChecked, cursor } } = useMemo(() => { if (!dat) return <CommentsFlatSkeleton />
if (!data && !ssrData) return { notifications: {} }
return data || ssrData
}, [data, ssrData])
const [fresh, old] = useMemo(() => {
if (!notifications) return [[], []]
return notifications.reduce((result, n) => {
result[new Date(n.sortTime).getTime() > lastChecked ? 0 : 1].push(n)
return result
},
[[], []])
}, [notifications, lastChecked])
if (!data && !ssrData) return <CommentsFlatSkeleton />
return ( return (
<> <>
<NotificationAlert /> {notifications.map(n =>
<div className='fresh'> <Notification
{earn && <Notification n={earn} key='earn' />} n={n} key={nid(n)}
{fresh.map((n, i) => ( fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt)}
<Notification n={n} key={n.__typename + n.id + n.sortTime} /> />)}
))}
</div>
{old.map((n, i) => (
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
))}
<MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' /> <MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' />
</> </>
) )

View File

@ -1,16 +1,16 @@
.clickToContext {
border-radius: .4rem;
padding: .2rem 0;
cursor: pointer;
}
.clickToContext:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.fresh { .fresh {
background-color: rgba(0, 0, 0, 0.03); background-color: rgba(128, 128, 128, 0.1);
border-radius: .4rem; border-radius: 0;
}
.fresh:not(.fresh ~ .fresh) {
border-top-left-radius: .4rem;
border-top-right-radius: .4rem;
}
.fresh:has(+ :not(.fresh)) {
border-bottom-left-radius: .4rem;
border-bottom-right-radius: .4rem;
} }
.alertBtn { .alertBtn {

View File

@ -4,6 +4,7 @@ import { fixedDecimal } from '../lib/format'
import { useMe } from './me' import { useMe } from './me'
import { PRICE } from '../fragments/price' import { PRICE } from '../fragments/price'
import { CURRENCY_SYMBOLS } from '../lib/currency' import { CURRENCY_SYMBOLS } from '../lib/currency'
import { SSR } from '../lib/constants'
export const PriceContext = React.createContext({ export const PriceContext = React.createContext({
price: null, price: null,
@ -19,9 +20,13 @@ export function PriceProvider ({ price, children }) {
const fiatCurrency = me?.fiatCurrency const fiatCurrency = me?.fiatCurrency
const { data } = useQuery(PRICE, { const { data } = useQuery(PRICE, {
variables: { fiatCurrency }, variables: { fiatCurrency },
...(SSR
? {}
: {
pollInterval: 30000, pollInterval: 30000,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
})
const contextValue = { const contextValue = {
price: data?.price || price, price: data?.price || price,

View File

@ -16,7 +16,7 @@
.searchSection.solid { .searchSection.solid {
pointer-events: auto; pointer-events: auto;
background: var(--theme-body); background: var(--bs-body-bg);
box-shadow: 0 -4px 12px hsl(0deg 0% 59% / 10%); box-shadow: 0 -4px 12px hsl(0deg 0% 59% / 10%);
} }

View File

@ -32,7 +32,7 @@ export default function TopHeader ({ sub, cat }) {
} }
const what = cat const what = cat
const by = router.query.by || '' const by = router.query.by || (what === 'stackers' ? 'stacked' : 'votes')
const when = router.query.when || '' const when = router.query.when || ''
return ( return (

View File

@ -27,7 +27,7 @@
} }
.cover { .cover {
background: var(--theme-body); background: var(--bs-body-bg);
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
mix-blend-mode: color; mix-blend-mode: color;

27
components/use-data.js Normal file
View File

@ -0,0 +1,27 @@
import { useEffect, useRef, useState } from 'react'
/*
What we want is to use ssrData if it exists, until cache data changes
... this prevents item list jitter where the intially rendered items
are stale until the cache is rewritten with incoming ssrData
*/
export function useData (data, ssrData) {
// when fresh is true, it means data has been updated after the initial render and it's populated
const [fresh, setFresh] = useState(false)
// on first render, we want to use ssrData if it's available
// it's only unavailable on back/forward navigation
const ref = useRef(true)
const firstRender = ref.current
ref.current = false
useEffect(() => {
if (!firstRender && !fresh && data) setFresh(true)
}, [data])
// if we don't have data yet, use ssrData
// if we have data, but it's not fresh, use ssrData
// unless we don't have ssrData
if (!data || (!fresh && ssrData)) return ssrData
return data
}

View File

@ -7,6 +7,7 @@ import userStyles from './user-header.module.css'
import { useEffect, useMemo, useState } from 'react' import { 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'
// all of this nonsense is to show the stat we are sorting by first // all of this nonsense is to show the stat we are sorting by first
const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>) const Stacked = ({ user }) => (<span>{abbrNum(user.stacked)} stacked</span>)
@ -37,26 +38,25 @@ function seperate (arr, seperator) {
export default function UserList ({ ssrData, query, variables, destructureData }) { export default function UserList ({ ssrData, query, variables, destructureData }) {
const { data, fetchMore } = useQuery(query, { variables }) const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator)) const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
useEffect(() => { useEffect(() => {
if (variables?.by) {
// shift the stat we are sorting by to the front // shift the stat we are sorting by to the front
const comps = [...STAT_COMPONENTS] const comps = [...STAT_COMPONENTS]
setStatComps(seperate([...comps.splice(STAT_POS[variables.by], 1), ...comps], Seperator)) setStatComps(seperate([...comps.splice(STAT_POS[variables.by || 0], 1), ...comps], Seperator))
}
}, [variables?.by]) }, [variables?.by])
const { users, cursor } = useMemo(() => { const { users, cursor } = useMemo(() => {
if (!data && !ssrData) return {} if (!dat) return {}
if (destructureData) { if (destructureData) {
return destructureData(data || ssrData) return destructureData(dat)
} else { } else {
return data || ssrData return dat
} }
}, [data, ssrData]) }, [dat])
if (!ssrData && !data) { if (!dat) {
return <UsersSkeleton /> return <UsersSkeleton />
} }

View File

@ -1,5 +1,6 @@
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client' import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
import { decodeCursor, LIMIT } from './cursor' import { decodeCursor, LIMIT } from './cursor'
import { SSR } from './constants'
function isFirstPage (cursor, existingThings) { function isFirstPage (cursor, existingThings) {
if (cursor) { if (cursor) {
@ -12,7 +13,6 @@ function isFirstPage (cursor, existingThings) {
} }
} }
const SSR = typeof window === 'undefined'
const defaultFetchPolicy = SSR ? 'cache-only' : 'cache-first' const defaultFetchPolicy = SSR ? 'cache-only' : 'cache-first'
const defaultNextFetchPolicy = SSR ? 'cache-only' : 'cache-first' const defaultNextFetchPolicy = SSR ? 'cache-only' : 'cache-first'
@ -158,14 +158,18 @@ function getClient (uri) {
assumeImmutableResults: true, assumeImmutableResults: true,
defaultOptions: { defaultOptions: {
watchQuery: { watchQuery: {
initialFetchPolicy: defaultFetchPolicy,
fetchPolicy: defaultFetchPolicy, fetchPolicy: defaultFetchPolicy,
nextFetchPolicy: defaultNextFetchPolicy, nextFetchPolicy: defaultNextFetchPolicy,
canonizeResults: true canonizeResults: true,
ssr: SSR
}, },
query: { query: {
initialFetchPolicy: defaultFetchPolicy,
fetchPolicy: defaultFetchPolicy, fetchPolicy: defaultFetchPolicy,
nextFetchPolicy: defaultNextFetchPolicy, nextFetchPolicy: defaultNextFetchPolicy,
canonizeResults: true canonizeResults: true,
ssr: SSR
} }
} }
}) })

View File

@ -45,3 +45,6 @@ module.exports = {
ANON_POST_FEE: 1000, ANON_POST_FEE: 1000,
ANON_COMMENT_FEE: 100, ANON_COMMENT_FEE: 100,
} }
export const OLD_ITEM_DAYS = 3
export const SSR = typeof window === 'undefined'

View File

@ -14,13 +14,17 @@ export function commentsViewedAfterComment (rootId, createdAt) {
window.localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1) window.localStorage.setItem(`${COMMENTS_NUM_PREFIX}:${rootId}`, existingRootComments + 1)
} }
export function commentsViewedAt (item) {
return window.localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`)
}
export function newComments (item) { export function newComments (item) {
if (!item.parentId) { if (!item.parentId) {
const commentsViewedAt = window.localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`) const viewedAt = commentsViewedAt(item)
const commentsViewNum = window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`) const viewNum = window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`)
if (commentsViewedAt && commentsViewNum) { if (viewedAt && viewNum) {
return commentsViewedAt < new Date(item.lastCommentAt).getTime() || commentsViewNum < item.ncomments return viewedAt < new Date(item.lastCommentAt).getTime() || viewNum < item.ncomments
} }
} }

View File

@ -1,296 +0,0 @@
/* eslint-disable */
'use strict'
Object.defineProperty(exports, '__esModule', {
value: true
})
exports.getCompoundId = getCompoundId
exports.Adapter = exports.PrismaLegacyAdapter = PrismaLegacyAdapter
const _crypto = require('crypto')
function getCompoundId (a, b) {
return (0, _crypto.createHash)('sha256').update(`${a}:${b}`).digest('hex')
}
function PrismaLegacyAdapter (config) {
const {
prisma,
modelMapping = {
User: 'user',
Account: 'account',
Session: 'session',
VerificationRequest: 'verificationRequest'
}
} = config
const {
User,
Account,
Session,
VerificationRequest
} = modelMapping
return {
async getAdapter ({
session: {
maxAge,
updateAge
},
secret,
...appOptions
}) {
const sessionMaxAge = maxAge * 1000
const sessionUpdateAge = updateAge * 1000
const hashToken = token => (0, _crypto.createHash)('sha256').update(`${token}${secret}`).digest('hex')
return {
displayName: 'PRISMA_LEGACY',
createUser (profile) {
let _profile$emailVerifie
return prisma[User].create({
data: {
name: profile.name,
email: profile.email,
image: profile.image,
emailVerified: (_profile$emailVerifie = profile.emailVerified) === null || _profile$emailVerifie === void 0 ? void 0 : _profile$emailVerifie.toISOString()
}
})
},
getUser (id) {
return prisma[User].findUnique({
where: {
id: Number(id)
}
})
},
getUserByEmail (email) {
if (email) {
return prisma[User].findUnique({
where: {
email
}
})
}
return null
},
async getUserByProviderAccountId (providerId, providerAccountId) {
const account = await prisma[Account].findUnique({
where: {
compoundId: getCompoundId(providerId, providerAccountId)
}
})
if (account) {
return prisma[User].findUnique({
where: {
id: account.userId
}
})
}
return null
},
updateUser (user) {
const {
id,
name,
email,
image,
emailVerified
} = user
return prisma[User].update({
where: {
id
},
data: {
name,
email,
image,
emailVerified: emailVerified === null || emailVerified === void 0 ? void 0 : emailVerified.toISOString()
}
})
},
deleteUser (userId) {
return prisma[User].delete({
where: {
id: userId
}
})
},
linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
return prisma[Account].create({
data: {
accessToken,
refreshToken,
compoundId: getCompoundId(providerId, providerAccountId),
providerAccountId: `${providerAccountId}`,
providerId,
providerType,
accessTokenExpires,
userId
}
})
},
unlinkAccount (_, providerId, providerAccountId) {
return prisma[Account].delete({
where: {
compoundId: getCompoundId(providerId, providerAccountId)
}
})
},
createSession (user) {
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires.toISOString()
}
return prisma[Session].create({
data: {
expires,
userId: user.id,
sessionToken: (0, _crypto.randomBytes)(32).toString('hex'),
accessToken: (0, _crypto.randomBytes)(32).toString('hex')
}
})
},
async getSession (sessionToken) {
const session = await prisma[Session].findUnique({
where: {
sessionToken
}
})
if (session !== null && session !== void 0 && session.expires && new Date() > session.expires) {
await prisma[Session].delete({
where: {
sessionToken
}
})
return null
}
return session
},
updateSession (session, force) {
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
const dateSessionIsDueToBeUpdated = new Date(session.expires)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
if (new Date() > dateSessionIsDueToBeUpdated) {
const newExpiryDate = new Date()
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
session.expires = newExpiryDate
} else if (!force) {
return null
}
} else {
if (!force) {
return null
}
}
const {
id,
expires
} = session
return prisma[Session].update({
where: {
id
},
data: {
expires: expires.toISOString()
}
})
},
deleteSession (sessionToken) {
return prisma[Session].delete({
where: {
sessionToken
}
})
},
async createVerificationRequest (identifier, url, token, _, provider) {
const {
sendVerificationRequest,
maxAge
} = provider
let expires = null
if (maxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + maxAge * 1000)
expires = dateExpires.toISOString()
}
const verificationRequest = await prisma[VerificationRequest].create({
data: {
identifier,
token: hashToken(token),
expires
}
})
await sendVerificationRequest({
identifier,
url,
token,
baseUrl: appOptions.baseUrl,
provider
})
return verificationRequest
},
async getVerificationRequest (identifier, token) {
const hashedToken = hashToken(token)
const verificationRequest = await prisma[VerificationRequest].findFirst({
where: {
identifier,
token: hashedToken
}
})
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
await prisma[VerificationRequest].deleteMany({
where: {
identifier,
token: hashedToken
}
})
return null
}
return verificationRequest
},
async deleteVerificationRequest (identifier, token) {
await prisma[VerificationRequest].deleteMany({
where: {
identifier,
token: hashToken(token)
}
})
}
}
}
}
}

View File

@ -40,12 +40,6 @@ module.exports = withPlausibleProxy()({
source: '/_next/:asset*', source: '/_next/:asset*',
headers: corsHeaders headers: corsHeaders
}, },
{
source: '/dark.js',
headers: [
...corsHeaders
]
},
{ {
source: '/.well-known/:slug*', source: '/.well-known/:slug*',
headers: [ headers: [
@ -120,6 +114,10 @@ module.exports = withPlausibleProxy()({
source: '/.well-known/web-app-origin-association', source: '/.well-known/web-app-origin-association',
destination: '/api/web-app-origin-association' destination: '/api/web-app-origin-association'
}, },
{
source: '/~:sub/:slug*\\?:query*',
destination: '/~/:slug*?:query*&sub=:sub'
},
{ {
source: '/~:sub/:slug*', source: '/~:sub/:slug*',
destination: '/~/:slug*?sub=:sub' destination: '/~/:slug*?sub=:sub'

520
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,11 @@
"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' next start -p $PORT" "start": "NODE_OPTIONS='--trace-warnings' next start -p $PORT --keepAliveTimeout 120000"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.17", "@apollo/client": "^3.7.17",
"@apollo/server": "^4.8.1", "@apollo/server": "^4.9.0",
"@as-integrations/next": "^2.0.1", "@as-integrations/next": "^2.0.1",
"@auth/prisma-adapter": "^1.0.1", "@auth/prisma-adapter": "^1.0.1",
"@graphql-tools/schema": "^10.0.0", "@graphql-tools/schema": "^10.0.0",
@ -22,12 +22,11 @@
"acorn": "^8.10.0", "acorn": "^8.10.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
"async-retry": "^1.3.1", "async-retry": "^1.3.1",
"aws-sdk": "^2.1422.0", "aws-sdk": "^2.1425.0",
"babel-plugin-inline-react-svg": "^2.0.2", "babel-plugin-inline-react-svg": "^2.0.2",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"bolt11": "^1.4.1", "bolt11": "^1.4.1",
"bootstrap": "^5.3.0", "bootstrap": "^5.3.1",
"browserslist": "^4.21.4",
"canonical-json": "0.0.4", "canonical-json": "0.0.4",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
@ -45,18 +44,19 @@
"mdast-util-gfm": "^3.0.0", "mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"next": "^13.4.12", "next": "^13.4.13-canary.12",
"next-auth": "^4.22.3", "next-auth": "^4.22.3",
"next-plausible": "^3.10.1", "next-plausible": "^3.10.1",
"next-seo": "^6.1.0", "next-seo": "^6.1.0",
"nextjs-progressbar": "0.0.16",
"node-s3-url-encode": "^0.0.4", "node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.4", "nodemailer": "^6.9.4",
"nostr": "^0.2.8", "nostr": "^0.2.8",
"nprogress": "^0.2.0",
"opentimestamps": "^0.4.9", "opentimestamps": "^0.4.9",
"page-metadata-parser": "^1.1.4", "page-metadata-parser": "^1.1.4",
"pageres": "^7.1.0", "pageres": "^7.1.0",
"pg-boss": "^9.0.3", "pg-boss": "^9.0.3",
"prisma": "^5.0.0",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
@ -75,11 +75,11 @@
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remove-markdown": "^0.5.0", "remove-markdown": "^0.5.0",
"sass": "^1.64.1", "sass": "^1.64.1",
"tldts": "^6.0.12", "tldts": "^6.0.13",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-unshort": "^6.1.0", "url-unshort": "^6.1.0",
"web-push": "^3.6.2", "web-push": "^3.6.4",
"webln": "^0.3.2", "webln": "^0.3.2",
"webpack": "^5.88.2", "webpack": "^5.88.2",
"workbox-navigation-preload": "^7.0.0", "workbox-navigation-preload": "^7.0.0",
@ -92,7 +92,7 @@
"yup": "^1.2.0" "yup": "^1.2.0"
}, },
"engines": { "engines": {
"node": "18.16.1" "node": "18.17.0"
}, },
"standard": { "standard": {
"parser": "@babel/eslint-parser", "parser": "@babel/eslint-parser",
@ -107,8 +107,7 @@
"@babel/core": "^7.22.9", "@babel/core": "^7.22.9",
"@babel/eslint-parser": "^7.22.9", "@babel/eslint-parser": "^7.22.9",
"@next/eslint-plugin-next": "^13.4.12", "@next/eslint-plugin-next": "^13.4.12",
"eslint": "^8.45.0", "eslint": "^8.46.0",
"prisma": "^5.0.0",
"standard": "^17.1.0" "standard": "^17.1.0"
} }
} }

View File

@ -3,7 +3,6 @@ import { ApolloProvider, gql } from '@apollo/client'
import { MeProvider } from '../components/me' import { MeProvider } from '../components/me'
import PlausibleProvider from 'next-plausible' import PlausibleProvider from 'next-plausible'
import getApolloClient from '../lib/apollo' import getApolloClient from '../lib/apollo'
import NextNProgress from 'nextjs-progressbar'
import { PriceProvider } from '../components/price' import { PriceProvider } from '../components/price'
import Head from 'next/head' import Head from 'next/head'
import { useRouter } from 'next/dist/client/router' import { useRouter } from 'next/dist/client/router'
@ -12,8 +11,13 @@ import { ShowModalProvider } from '../components/modal'
import ErrorBoundary from '../components/error-boundary' import ErrorBoundary from '../components/error-boundary'
import { LightningProvider } from '../components/lightning' import { LightningProvider } from '../components/lightning'
import { ServiceWorkerProvider } from '../components/serviceworker' import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
const SSR = typeof window === 'undefined' NProgress.configure({
showSpinner: false
})
function writeQuery (client, apollo, data) { function writeQuery (client, apollo, data) {
if (apollo && data) { if (apollo && data) {
@ -21,8 +25,8 @@ function writeQuery (client, apollo, data) {
query: gql`${apollo.query}`, query: gql`${apollo.query}`,
data, data,
variables: apollo.variables, variables: apollo.variables,
broadcast: !SSR, overwrite: SSR,
overwrite: SSR broadcast: false
}) })
} }
} }
@ -32,48 +36,49 @@ function MyApp ({ Component, pageProps: { ...props } }) {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
const nprogressStart = (_, { shallow }) => !shallow && NProgress.start()
const nprogressDone = (_, { shallow }) => !shallow && NProgress.done()
router.events.on('routeChangeStart', nprogressStart)
router.events.on('routeChangeComplete', nprogressDone)
router.events.on('routeChangeError', nprogressDone)
if (!props?.apollo) return
// HACK: 'cause there's no way to tell Next to skip SSR // HACK: 'cause there's no way to tell Next to skip SSR
// So every page load, we modify the route in browser history // So every page load, we modify the route in browser history
// to point to the same page but without SSR, ie ?nodata=true // to point to the same page but without SSR, ie ?nodata=true
// this nodata var will get passed to the server on back/foward and // this nodata var will get passed to the server on back/foward and
// 1. prevent data from reloading and 2. perserve scroll // 1. prevent data from reloading and 2. perserve scroll
// (2) is not possible while intercepting nav with beforePopState // (2) is not possible while intercepting nav with beforePopState
if (router.query.nodata) return
router.replace({ router.replace({
pathname: router.pathname, pathname: router.pathname,
query: { ...router.query, nodata: true } query: { ...router.query, nodata: true }
}, router.asPath, { ...router.options, shallow: true }).catch((e) => { }, router.asPath, { ...router.options, shallow: true }).catch((e) => {
// workaround for https://github.com/vercel/next.js/issues/37362 // workaround for https://github.com/vercel/next.js/issues/37362
if (!e.cancelled) { if (!e.cancelled) {
console.log(e)
throw e throw e
} }
}) })
}, [router.pathname, router.query])
return () => {
router.events.off('routeChangeStart', nprogressStart)
router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone)
}
}, [router.asPath, props?.apollo])
/* /*
If we are on the client, we populate the apollo cache with the If we are on the client, we populate the apollo cache with the
ssr data ssr data
*/ */
const { apollo, ssrData, me, price, ...otherProps } = props const { apollo, ssrData, me, price, ...otherProps } = props
// if we are on the server, useEffect won't run
if (SSR && client) {
writeQuery(client, apollo, ssrData)
}
useEffect(() => { useEffect(() => {
writeQuery(client, apollo, ssrData) writeQuery(client, apollo, ssrData)
}, [client, apollo, ssrData]) }, [client, apollo, ssrData])
return ( return (
<> <>
<NextNProgress
color='var(--bs-primary)'
startPosition={0.3}
stopDelayMs={200}
height={2}
showOnShallow={false}
options={{ showSpinner: false }}
/>
<Head> <Head>
<meta name='viewport' content='initial-scale=1.0, width=device-width' /> <meta name='viewport' content='initial-scale=1.0, width=device-width' />
</Head> </Head>

View File

@ -23,8 +23,43 @@ class MyDocument extends Document {
}} }}
/> />
<meta name='apple-mobile-web-app-capable' content='yes' /> <meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='theme-color' content='#000000' /> <meta name='theme-color' content='#121214' />
<link rel='apple-touch-icon' href='/icons/icon_x192.png' /> <link rel='apple-touch-icon' href='/icons/icon_x192.png' />
<Script id='dark-mode-js' strategy='beforeInteractive'>
{`const handleThemeChange = (dark) => {
const root = window.document.documentElement
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
}
const STORAGE_KEY = 'darkMode'
const PREFER_DARK_QUERY = '(prefers-color-scheme: dark)'
const getTheme = () => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
const supportsColorSchemeQuery = mql.media === PREFER_DARK_QUERY
let localStorageTheme = null
try {
localStorageTheme = window.localStorage.getItem(STORAGE_KEY)
} catch (err) {}
const localStorageExists = localStorageTheme !== null
if (localStorageExists) {
localStorageTheme = JSON.parse(localStorageTheme)
}
if (localStorageExists) {
return { user: true, dark: localStorageTheme }
} else if (supportsColorSchemeQuery) {
return { user: false, dark: mql.matches }
}
}
if (typeof window !== 'undefined') {
(function () {
const { dark } = getTheme()
handleThemeChange(dark)
})()
}`}
</Script>
<link rel='apple-touch-startup-image' media='screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Pro_Max_landscape.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Pro_Max_landscape.png' />
<link rel='apple-touch-startup-image' media='screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Pro_landscape.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Pro_landscape.png' />
<link rel='apple-touch-startup-image' media='screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' href='/splash/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png' />
@ -59,7 +94,6 @@ class MyDocument extends Document {
<link rel='apple-touch-startup-image' media='screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/10.2__iPad_portrait.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/10.2__iPad_portrait.png' />
<link rel='apple-touch-startup-image' media='screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png' />
<link rel='apple-touch-startup-image' media='screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/8.3__iPad_Mini_portrait.png' /> <link rel='apple-touch-startup-image' media='screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' href='/splash/8.3__iPad_Mini_portrait.png' />
<Script src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/dark.js`} crossOrigin='' strategy='beforeInteractive' type='module' />
</Head> </Head>
<body> <body>
<Main /> <Main />

View File

@ -130,7 +130,7 @@ const providers = [
}, },
profile: profile => { profile: profile => {
return { return {
...profile, id: profile.id,
name: profile.login name: profile.login
} }
} }
@ -140,7 +140,7 @@ const providers = [
clientSecret: process.env.TWITTER_SECRET, clientSecret: process.env.TWITTER_SECRET,
profile: profile => { profile: profile => {
return { return {
...profile, id: profile.id,
name: profile.screen_name name: profile.screen_name
} }
} }

View File

@ -22,7 +22,7 @@ const apolloServer = new ApolloServer({
return (error, result) => { return (error, result) => {
const end = process.hrtime.bigint() const end = process.hrtime.bigint()
const ms = (end - start) / 1000000n const ms = (end - start) / 1000000n
if (ms > 5) { if (ms > 50) {
console.log(`Field ${info.parentType.name}.${info.fieldName} took ${ms}ms`) console.log(`Field ${info.parentType.name}.${info.fieldName} took ${ms}ms`)
} }
if (error) { if (error) {

View File

@ -47,7 +47,7 @@ export default async ({ query: { username, amount, nostr } }, res) => {
await serialize(models, await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request}, models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${invoice.request},
${expiresAt}, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description})`) ${expiresAt}::timestamp, ${Number(amount)}, ${user.id}::INTEGER, ${noteStr || description})`)
return res.status(200).json({ return res.status(200).json({
pr: invoice.request, pr: invoice.request,

View File

@ -8,10 +8,10 @@ import { INVITE_FIELDS } from '../../fragments/invites'
import getSSRApolloClient from '../../api/ssrApollo' import getSSRApolloClient from '../../api/ssrApollo'
import Link from 'next/link' import Link from 'next/link'
import { CenterLayout } from '../../components/layout' import { CenterLayout } from '../../components/layout'
import { authOptions } from '../api/auth/[...nextauth]' import { getAuthOptions } from '../api/auth/[...nextauth]'
export async function getServerSideProps ({ req, res, query: { id, error = null } }) { export async function getServerSideProps ({ req, res, query: { id, error = null } }) {
const session = await getServerSession(req, res, authOptions(req)) const session = await getServerSession(req, res, getAuthOptions(req))
const client = await getSSRApolloClient({ req, res }) const client = await getSSRApolloClient({ req, res })
const { data } = await client.query({ const { data } = await client.query({

View File

@ -7,6 +7,7 @@ import AccordianItem from '../../components/accordian-item'
import styles from '../../styles/invites.module.css' import styles from '../../styles/invites.module.css'
import Invite from '../../components/invite' import Invite from '../../components/invite'
import { inviteSchema } from '../../lib/validate' import { inviteSchema } from '../../lib/validate'
import { SSR } from '../../lib/constants'
function InviteForm () { function InviteForm () {
const [createInvite] = useMutation( const [createInvite] = useMutation(
@ -93,7 +94,7 @@ export default function Invites () {
...InviteFields ...InviteFields
} }
} }
`, { fetchPolicy: 'cache-and-network' }) `, SSR ? {} : { fetchPolicy: 'cache-and-network' })
const [active, inactive] = data && data.invites const [active, inactive] = data && data.invites
? data.invites.reduce((result, invite) => { ? data.invites.reduce((result, invite) => {

View File

@ -4,10 +4,13 @@ import { QrSkeleton } from '../../components/qr'
import { CenterLayout } from '../../components/layout' import { CenterLayout } from '../../components/layout'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { INVOICE } from '../../fragments/wallet' import { INVOICE } from '../../fragments/wallet'
import { SSR } from '../../lib/constants'
export default function FullInvoice ({ id }) { export default function FullInvoice ({ id }) {
const router = useRouter() const router = useRouter()
const { data, error } = useQuery(INVOICE, { const { data, error } = useQuery(INVOICE, SSR
? {}
: {
pollInterval: 1000, pollInterval: 1000,
variables: { id: router.query.id }, variables: { id: router.query.id },
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'

View File

@ -37,7 +37,7 @@ function Ots ({ item }) {
: ( : (
<pre <pre
className='mb-2 p-2 rounded' className='mb-2 p-2 rounded'
style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word', border: '1px solid var(--theme-borderColor)', color: 'var(--theme-color)' }} style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word', border: '1px solid var(--theme-borderColor)', color: 'var(--bs-body-color)' }}
>{itemString} >{itemString}
</pre>)} </pre>)}
<Button href={`/api/ots/preimage/${item.id}`} className='mt-1' variant='grey-medium'>download preimage</Button> <Button href={`/api/ots/preimage/${item.id}`} className='mt-1' variant='grey-medium'>download preimage</Button>

View File

@ -1,13 +1,27 @@
import { useEffect } from 'react'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
import Layout from '../components/layout' import Layout from '../components/layout'
import Notifications from '../components/notifications' import Notifications, { NotificationAlert } from '../components/notifications'
import { NOTIFICATIONS } from '../fragments/notifications' import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import { useApolloClient } from '@apollo/client'
export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS) export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS)
export default function NotificationPage ({ ssrData }) { export default function NotificationPage ({ ssrData }) {
const client = useApolloClient()
useEffect(() => {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: false
}
})
}, [])
return ( return (
<Layout> <Layout>
<NotificationAlert />
<Notifications ssrData={ssrData} /> <Notifications ssrData={ssrData} />
</Layout> </Layout>
) )

View File

@ -1,5 +1,5 @@
import { gql } from 'graphql-tag' import { gql } from 'graphql-tag'
import { useEffect, useState } from 'react' import { useMemo } from 'react'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
@ -13,6 +13,7 @@ import { abbrNum } from '../lib/format'
import PageLoading from '../components/page-loading' import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal' import { useShowModal } from '../components/modal'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { SSR } from '../lib/constants'
const GrowthPieChart = dynamic(() => import('../components/charts').then(mod => mod.GrowthPieChart), { const GrowthPieChart = dynamic(() => import('../components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <div>Loading...</div> loading: () => <div>Loading...</div>
@ -47,11 +48,7 @@ function midnight (tz) {
export const getServerSideProps = getGetServerSideProps(REWARDS) export const getServerSideProps = getGetServerSideProps(REWARDS)
export function RewardLine ({ total }) { export function RewardLine ({ total }) {
const [threshold, setThreshold] = useState(0) const threshold = useMemo(() => midnight('America/Chicago'))
useEffect(() => {
setThreshold(midnight('America/Chicago'))
}, [])
return ( return (
<> <>
@ -59,14 +56,14 @@ export function RewardLine ({ total }) {
{threshold && {threshold &&
<Countdown <Countdown
date={threshold} date={threshold}
renderer={props => <small className='text-monospace'> {props.formatted.hours}:{props.formatted.minutes}:{props.formatted.seconds}</small>} renderer={props => <small className='text-monospace' suppressHydrationWarning> {props.formatted.hours}:{props.formatted.minutes}:{props.formatted.seconds}</small>}
/>} />}
</> </>
) )
} }
export default function Rewards ({ ssrData }) { export default function Rewards ({ ssrData }) {
const { data } = useQuery(REWARDS, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(REWARDS, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (!data && !ssrData) return <PageLoading /> if (!data && !ssrData) return <PageLoading />
const { expectedRewards: { total, sources } } = data || ssrData const { expectedRewards: { total, sources } } = data || ssrData

View File

@ -25,7 +25,7 @@ function satusClass (status) {
switch (status) { switch (status) {
case 'CONFIRMED': case 'CONFIRMED':
return '' return 'text-reset'
case 'PENDING': case 'PENDING':
return 'text-muted' return 'text-muted'
default: default:
@ -78,9 +78,9 @@ function Satus ({ status }) {
} }
return ( return (
<div> <span className='d-block'>
<Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small> <Icon /><small className={`text-${color} fw-bold ms-2`}>{desc}</small>
</div> </span>
) )
} }
@ -110,10 +110,10 @@ function Detail ({ fact }) {
if (!fact.item) { if (!fact.item) {
return ( return (
<div className='px-3'> <div className='px-3'>
<div className={satusClass(fact.status)}> <Link className={satusClass(fact.status)} href={`/${fact.type}s/${fact.factId}`}>
{fact.description || 'no invoice description'} {fact.description || 'no invoice description'}
</div>
<Satus status={fact.status} /> <Satus status={fact.status} />
</Link>
</div> </div>
) )
} }
@ -206,7 +206,7 @@ export default function Satistics ({ ssrData }) {
<div className={[styles.type, styles.head].join(' ')}>type</div> <div className={[styles.type, styles.head].join(' ')}>type</div>
<div className={[styles.detail, styles.head].join(' ')}>detail</div> <div className={[styles.detail, styles.head].join(' ')}>detail</div>
<div className={[styles.sats, styles.head].join(' ')}>sats</div> <div className={[styles.sats, styles.head].join(' ')}>sats</div>
{facts.map(f => <Fact key={f.factId} fact={f} />)} {facts.map(f => <Fact key={f.id} fact={f} />)}
</div> </div>
</div> </div>
<MoreFooter cursor={cursor} count={facts?.length} fetchMore={fetchMore} Skeleton={PageLoading} /> <MoreFooter cursor={cursor} count={facts?.length} fetchMore={fetchMore} Skeleton={PageLoading} />

View File

@ -245,7 +245,7 @@ export default function Settings ({ ssrData }) {
name='greeterMode' name='greeterMode'
/> />
<AccordianItem <AccordianItem
headerColor='var(--theme-color)' headerColor='var(--bs-body-color)'
show={settings?.nostrPubkey} show={settings?.nostrPubkey}
header={<h4 className='text-left'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>} header={<h4 className='text-left'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>}
body={ body={
@ -372,14 +372,19 @@ function AuthMethods ({ methods }) {
return ( return (
<> <>
<div className='form-label mt-3'>auth methods</div> <div className='form-label mt-3'>auth methods</div>
{err && <Alert variant='danger' onClose={() => { {err && (
<Alert
variant='danger' onClose={() => {
const { pathname, query: { error, nodata, ...rest } } = router const { pathname, query: { error, nodata, ...rest } } = router
router.replace({ router.replace({
pathname, pathname,
query: { nodata, ...rest } query: { nodata, ...rest }
}, { pathname, query: { ...rest } }, { shallow: true }) }, { pathname, query: { ...rest } }, { shallow: true })
setErr(undefined) setErr(undefined)
}} dismissible>{err}</Alert>} }} dismissible
>{err}
</Alert>
)}
{providers?.map(provider => { {providers?.map(provider => {
if (provider === 'email') { if (provider === 'email') {

View File

@ -14,6 +14,7 @@ import Alert from 'react-bootstrap/Alert'
import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '../fragments/wallet' import { CREATE_WITHDRAWL, SEND_TO_LNADDR } from '../fragments/wallet'
import { getGetServerSideProps } from '../api/ssrApollo' import { getGetServerSideProps } from '../api/ssrApollo'
import { amountSchema, lnAddrSchema, withdrawlSchema } from '../lib/validate' import { amountSchema, lnAddrSchema, withdrawlSchema } from '../lib/validate'
import { SSR } from '../lib/constants'
export const getServerSideProps = getGetServerSideProps() export const getServerSideProps = getGetServerSideProps()
@ -210,7 +211,7 @@ function LnQRWith ({ k1, encodedUrl }) {
k1 k1
} }
}` }`
const { data } = useQuery(query, { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' }) const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
if (data?.lnWith?.withdrawalId) { if (data?.lnWith?.withdrawalId) {
router.push(`/withdrawals/${data.lnWith.withdrawalId}`) router.push(`/withdrawals/${data.lnWith.withdrawalId}`)

View File

@ -6,6 +6,7 @@ import InvoiceStatus from '../../components/invoice-status'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { WITHDRAWL } from '../../fragments/wallet' import { WITHDRAWL } from '../../fragments/wallet'
import Link from 'next/link' import Link from 'next/link'
import { SSR } from '../../lib/constants'
export default function Withdrawl () { export default function Withdrawl () {
return ( return (
@ -31,7 +32,9 @@ export function WithdrawlSkeleton ({ status }) {
function LoadWithdrawl () { function LoadWithdrawl () {
const router = useRouter() const router = useRouter()
const { loading, error, data } = useQuery(WITHDRAWL, { const { loading, error, data } = useQuery(WITHDRAWL, SSR
? {}
: {
variables: { id: router.query.id }, variables: { id: router.query.id },
pollInterval: 1000, pollInterval: 1000,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'

View File

@ -0,0 +1,15 @@
-- prod is set to utc by default but dev might not be
CREATE OR REPLACE FUNCTION set_timezone_utc_currentdb()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
EXECUTE 'ALTER DATABASE '||current_database()||' SET TIMEZONE TO ''UTC''';
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT set_timezone_utc_currentdb();

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "accounts" ADD COLUMN "oauth_token" TEXT,
ADD COLUMN "oauth_token_secret" TEXT;

View File

@ -443,6 +443,10 @@ model Account {
id_token String? id_token String?
session_state String? session_state String?
// twitter oauth 1.0 needs these https://authjs.dev/reference/core/providers_twitter#notes
oauth_token String?
oauth_token_secret String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId]) @@unique([provider, providerAccountId])

33
public/dark-mode.js Normal file
View File

@ -0,0 +1,33 @@
const handleThemeChange = (dark) => {
const root = window.document.documentElement
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
}
const STORAGE_KEY = 'darkMode'
const PREFER_DARK_QUERY = '(prefers-color-scheme: dark)'
const getTheme = () => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
const supportsColorSchemeQuery = mql.media === PREFER_DARK_QUERY
let localStorageTheme = null
try {
localStorageTheme = window.localStorage.getItem(STORAGE_KEY)
} catch (err) {}
const localStorageExists = localStorageTheme !== null
if (localStorageExists) {
localStorageTheme = JSON.parse(localStorageTheme)
}
if (localStorageExists) {
return { user: true, dark: localStorageTheme }
} else if (supportsColorSchemeQuery) {
return { user: false, dark: mql.matches }
}
}
if (typeof window !== 'undefined') {
(function () {
const { dark } = getTheme()
handleThemeChange(dark)
})()
}

View File

@ -1,112 +0,0 @@
const COLORS = {
light: {
body: '#f5f5f7',
color: '#212529',
navbarVariant: 'light',
navLink: 'rgba(0, 0, 0, 0.55)',
navLinkFocus: 'rgba(0, 0, 0, 0.7)',
navLinkActive: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ced4da',
inputBg: '#ffffff',
inputDisabledBg: '#e9ecef',
dropdownItemColor: 'rgba(0, 0, 0, 0.7)',
dropdownItemColorHover: 'rgba(0, 0, 0, 0.9)',
commentBg: 'rgba(0, 0, 0, 0.03)',
clickToContextColor: 'rgba(0, 0, 0, 0.07)',
brandColor: 'rgba(0, 0, 0, 0.9)',
grey: '#707070',
link: '#007cbe',
toolbarActive: 'rgba(0, 0, 0, 0.10)',
toolbarHover: 'rgba(0, 0, 0, 0.20)',
toolbar: '#ffffff',
quoteBar: 'rgb(206, 208, 212)',
quoteColor: 'rgb(101, 103, 107)',
linkHover: '#004a72',
linkVisited: '#537587'
},
dark: {
body: '#000000',
inputBg: '#000000',
inputDisabledBg: '#000000',
navLink: 'rgba(255, 255, 255, 0.55)',
navLinkFocus: 'rgba(255, 255, 255, 0.75)',
navLinkActive: 'rgba(255, 255, 255, 0.9)',
borderColor: 'rgba(255, 255, 255, 0.5)',
dropdownItemColor: 'rgba(255, 255, 255, 0.7)',
dropdownItemColorHover: 'rgba(255, 255, 255, 0.9)',
commentBg: 'rgba(255, 255, 255, 0.04)',
clickToContextColor: 'rgba(255, 255, 255, 0.2)',
color: '#f8f9fa',
brandColor: 'var(--bs-primary)',
grey: '#969696',
link: '#2e99d1',
toolbarActive: 'rgba(255, 255, 255, 0.10)',
toolbarHover: 'rgba(255, 255, 255, 0.20)',
toolbar: '#3e3f3f',
quoteBar: 'rgb(158, 159, 163)',
quoteColor: 'rgb(141, 144, 150)',
linkHover: '#007cbe',
linkVisited: '#56798E'
}
}
const handleThemeChange = (dark) => {
const root = window.document.documentElement
const colors = COLORS[dark ? 'dark' : 'light']
Object.entries(colors).forEach(([varName, value]) => {
const cssVarName = `--theme-${varName}`
root.style.setProperty(cssVarName, value)
})
}
const STORAGE_KEY = 'darkMode'
const PREFER_DARK_QUERY = '(prefers-color-scheme: dark)'
export const getTheme = () => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
const supportsColorSchemeQuery = mql.media === PREFER_DARK_QUERY
let localStorageTheme = null
try {
localStorageTheme = window.localStorage.getItem(STORAGE_KEY)
} catch (err) {}
const localStorageExists = localStorageTheme !== null
if (localStorageExists) {
localStorageTheme = JSON.parse(localStorageTheme)
}
if (localStorageExists) {
return { user: true, dark: localStorageTheme }
} else if (supportsColorSchemeQuery) {
return { user: false, dark: mql.matches }
}
}
export const setTheme = (dark) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(dark))
handleThemeChange(dark)
}
export const listenForThemeChange = (onChange) => {
const mql = window.matchMedia(PREFER_DARK_QUERY)
mql.onchange = mql => {
const { user, dark } = getTheme()
if (!user) {
handleThemeChange(dark)
onChange({ user, dark })
}
}
window.onstorage = e => {
if (e.key === STORAGE_KEY) {
const dark = JSON.parse(e.newValue)
setTheme(dark)
onChange({ user: true, dark })
}
}
}
if (typeof window !== 'undefined') {
(function () {
const { dark } = getTheme()
handleThemeChange(dark)
})()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 936 KiB

View File

@ -89,7 +89,7 @@
], ],
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"theme_color": "#000000", "theme_color": "#121214",
"background_color": "#FADA5E", "background_color": "#FADA5E",
"id": "/", "id": "/",
"start_url": "/", "start_url": "/",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -23,7 +23,10 @@ $theme-colors: (
"grey-darkmode": #8c8c8c, "grey-darkmode": #8c8c8c,
); );
$body-bg: #f5f5f7; $body-bg: #fcfcff;
$body-bg-dark: #121214;
$body-color: #212529;
$body-color-dark: #f0f0f0;
$border-radius: .4rem; $border-radius: .4rem;
$enable-transitions: false; $enable-transitions: false;
$enable-gradients: false; $enable-gradients: false;
@ -41,6 +44,10 @@ $btn-font-weight: bold;
$btn-focus-width: 0; $btn-focus-width: 0;
$btn-border-width: 0; $btn-border-width: 0;
$btn-focus-box-shadow: none; $btn-focus-box-shadow: none;
$form-invalid-border-color: #c03221;
$form-invalid-border-color-dark: #c03221;
$form-invalid-color: #c03221;
$form-invalid-color-dark: #c03221;
$alert-border-width: 0; $alert-border-width: 0;
$close-text-shadow: none; $close-text-shadow: none;
$close-color: inherit; $close-color: inherit;
@ -69,11 +76,12 @@ $nav-tabs-link-hover-border-color: transparent;
$nav-tabs-link-active-border-color: #ced4da #ced4da $nav-tabs-link-active-bg; $nav-tabs-link-active-border-color: #ced4da #ced4da $nav-tabs-link-active-bg;
$form-check-input-checked-color: var(--bs-primary); $form-check-input-checked-color: var(--bs-primary);
$form-check-input-checked-bg-color: var(--bs-primary); $form-check-input-checked-bg-color: var(--bs-primary);
$popover-bg: var(--theme-body); $popover-bg: var(--bs-body-bg);
$form-check-input-checked-color: #000; $form-check-input-checked-color: #000;
$tooltip-bg: #5c8001; $tooltip-bg: #5c8001;
$form-select-indicator-color: #808080; $form-select-indicator-color: #808080;
$form-select-indicator: url("data:image/svg+xml, %3Csvg fill='#{$form-select-indicator-color}' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z'%3E%3C/path%3E%3C/svg%3E%0A"); $form-select-indicator: url("data:image/svg+xml, %3Csvg fill='#{$form-select-indicator-color}' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z'%3E%3C/path%3E%3C/svg%3E%0A");
$form-select-indicator-dark: url("data:image/svg+xml, %3Csvg fill='#{$form-select-indicator-color}' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 15.0006L7.75732 10.758L9.17154 9.34375L12 12.1722L14.8284 9.34375L16.2426 10.758L12 15.0006Z'%3E%3C/path%3E%3C/svg%3E%0A");
$form-select-bg-position: right .25rem center; $form-select-bg-position: right .25rem center;
$form-select-bg-size: 1.5rem; $form-select-bg-size: 1.5rem;
$popover-body-padding-y: .5rem; $popover-body-padding-y: .5rem;
@ -81,6 +89,49 @@ $popover-max-width: 320px !default;
$popover-border-color: var(--theme-borderColor); $popover-border-color: var(--theme-borderColor);
$grid-gutter-width: 2rem; $grid-gutter-width: 2rem;
:root, [data-bs-theme=light] {
--theme-navLink: rgba(0, 0, 0, 0.55);
--theme-navLinkFocus: rgba(0, 0, 0, 0.7);
--theme-navLinkActive: rgba(0, 0, 0, 0.9);
--theme-borderColor: #ced4da;
--theme-inputBg: #ffffff;
--theme-inputDisabledBg: #e9ecef;
--theme-dropdownItemColor: rgba(0, 0, 0, 0.7);
--theme-dropdownItemColorHover: rgba(0, 0, 0, 0.9);
--theme-commentBg: rgba(0, 0, 0, 0.03);
--theme-clickToContextColor: rgba(0, 0, 0, 0.07);
--theme-brandColor: rgba(0, 0, 0, 0.9);
--theme-grey: #707070;
--theme-link: #007cbe;
--theme-quoteBar: rgb(206, 208, 212);
--theme-linkHover: #004a72;
--theme-linkVisited: #53758;
}
[data-bs-theme=dark] {
color-scheme: dark;
--theme-inputBg: #121211;
--theme-inputDisabledBg: #121211;
--theme-navLink: rgba(255, 255, 255, 0.55);
--theme-navLinkFocus: rgba(255, 255, 255, 0.75);
--theme-navLinkActive: rgba(255, 255, 255, 0.9);
--theme-borderColor: rgba(255, 255, 255, 0.5);
--theme-dropdownItemColor: rgba(255, 255, 255, 0.7);
--theme-dropdownItemColorHover: rgba(255, 255, 255, 0.9);
--theme-commentBg: rgba(255, 255, 255, 0.025);
--theme-clickToContextColor: rgba(255, 255, 255, 0.1);
--theme-brandColor: var(--bs-primary);
--theme-grey: #969696;
--theme-link: #2e99d1;
--theme-toolbarActive: rgba(255, 255, 255, 0.10);
--theme-toolbarHover: rgba(255, 255, 255, 0.20);
--theme-toolbar: #3e3f3f;
--theme-quoteBar: rgb(158, 159, 163);
--theme-quoteColor: rgb(141, 144, 150);
--theme-linkHover: #007cbe;
--theme-linkVisited: #56798E;
}
@import '../node_modules/bootstrap/scss/bootstrap.scss'; @import '../node_modules/bootstrap/scss/bootstrap.scss';
@media screen and (min-width: 899px) { @media screen and (min-width: 899px) {
@ -95,6 +146,15 @@ $grid-gutter-width: 2rem;
} }
} }
#nprogress .bar {
background: var(--bs-primary) !important;
height: 2px !important;
}
#nprogress .peg {
box-shadow: 0 0 10px var(--bs-primary), 0 0 5px var(--bs-primary) !important;
}
.standalone { .standalone {
display: none; display: none;
} }
@ -145,7 +205,7 @@ dl {
mark { mark {
background-color: #fada5e5e; background-color: #fada5e5e;
padding: 0 0.2rem; padding: 0 0.2rem;
color: var(--theme-color); color: var(--bs-body-color);
} }
.table-sm th, .table-sm th,
@ -172,8 +232,8 @@ mark {
} }
.table { .table {
color: var(--theme-color); color: var(--bs-body-color);
background-color: var(--theme-body); background-color: var(--bs-body-bg);
} }
.table th, .table th,
@ -183,13 +243,13 @@ mark {
} }
.table-hover tbody tr:hover { .table-hover tbody tr:hover {
color: var(--theme-color); color: var(--bs-body-color);
background-color: var(--theme-clickToContextColor); background-color: var(--theme-clickToContextColor);
} }
html, body { html, body {
background: var(--theme-body); background: var(--bs-body-bg) !important;
color: var(--theme-color); color: var(--bs-body-color) !important;
min-height: 100vh; min-height: 100vh;
min-height: 100svh; min-height: 100svh;
} }
@ -214,7 +274,7 @@ select,
div[contenteditable], div[contenteditable],
.form-control { .form-control {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
color: var(--theme-color); color: var(--bs-body-color);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
} }
@ -256,7 +316,7 @@ select:focus {
div[contenteditable]:focus, div[contenteditable]:focus,
.form-control:focus { .form-control:focus {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
color: var(--theme-color); color: var(--bs-body-color);
outline: 0; outline: 0;
box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%); box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
} }
@ -297,11 +357,6 @@ div[contenteditable]:disabled,
fill: #212529; fill: #212529;
} }
.fresh {
background-color: var(--theme-clickToContextColor);
border-radius: .4rem;
}
.modal-content { .modal-content {
background-color: var(--theme-inputBg); background-color: var(--theme-inputBg);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
@ -406,7 +461,7 @@ footer {
.input-group-text { .input-group-text {
background-color: var(--theme-clickToContextColor); background-color: var(--theme-clickToContextColor);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
color: var(--theme-color); color: var(--bs-body-color);
} }
textarea.form-control, textarea.form-control,
@ -445,8 +500,8 @@ div[contenteditable] {
} }
.popover { .popover {
color: var(--theme-color); color: var(--bs-body-color);
background-color: var(--theme-body); background-color: var(--bs-body-bg);
border-color: var(--theme-borderColor); border-color: var(--theme-borderColor);
} }
@ -455,7 +510,7 @@ div[contenteditable] {
} }
.popover .arrow::after { .popover .arrow::after {
border-top-color: var(--theme-body); border-top-color: var(--bs-body-bg);
} }
@ -566,7 +621,7 @@ div[contenteditable]:focus,
} }
.fill-theme-color { .fill-theme-color {
fill: var(--theme-color); fill: var(--bs-body-color);
} }
.fill-warning { .fill-warning {
@ -617,6 +672,34 @@ div[contenteditable]:focus,
animation: flash 2s linear 1; animation: flash 2s linear 1;
} }
@keyframes outline {
from {
box-shadow: inset 0 0 1px 1px var(--bs-info);
}
90% {
box-shadow: inset 0 0 1px 1px var(--bs-info);
}
to {
box-shadow: inset 0 0 0px 0px var(--bs-info);
}
}
.outline-it {
animation: outline 3s linear 1;
}
.outline-new-comment {
box-shadow: inset 0 0 1px 1px rgba(0, 123, 190, 0.25);
}
.outline-new-comment.outline-new-comment-unset {
box-shadow: none;
}
.outline-new-comment .outline-new-comment {
box-shadow: none;
}
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@ -679,14 +762,15 @@ div[contenteditable]:focus,
.tooltip-inner { .tooltip-inner {
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
color: #fff;
} }
.popover { .popover {
.popover-header { .popover-header {
background-color: var(--theme-body); background-color: var(--bs-body-bg);
color: var(--theme-color); color: var(--bs-body-color);
} }
.popover-body { .popover-body {
color: var(--theme-color); color: var(--bs-body-color);
} }
} }

View File

@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:16.16.0-bullseye FROM node:18.17.0-bullseye
ENV NODE_ENV=development ENV NODE_ENV=development

View File

@ -9,7 +9,7 @@ function earn ({ models }) {
console.log('running', name) console.log('running', name)
// compute how much sn earned today // compute how much sn earned today
let [{ sum }] = await models.$queryRaw` const [{ sum: actSum }] = await models.$queryRaw`
SELECT coalesce(sum("ItemAct".msats - coalesce("ReferralAct".msats, 0)), 0) as sum SELECT coalesce(sum("ItemAct".msats - coalesce("ReferralAct".msats, 0)), 0) as sum
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item".id JOIN "Item" ON "ItemAct"."itemId" = "Item".id
@ -21,7 +21,24 @@ function earn ({ models }) {
SELECT coalesce(sum(sats), 0) as sum SELECT coalesce(sum(sats), 0) as sum
FROM "Donation" FROM "Donation"
WHERE created_at > now_utc() - INTERVAL '1 day'` WHERE created_at > now_utc() - INTERVAL '1 day'`
sum += donatedSum * 1000n
// XXX prisma returns wonky types from raw queries ... so be extra
// careful with them
const sum = Number(actSum) + (Number(donatedSum) * 1000)
if (sum <= 0) {
console.log('done', name, 'no sats to award today')
return
}
// extra sanity check on rewards ... if it more than 1m sats, we
// probably have a bug somewhere
if (sum > 1000000000) {
console.log('done', name, 'error: too many sats to award today')
return
}
console.log(name, 'giving away', sum, 'msats')
/* /*
How earnings (used to) work: How earnings (used to) work:
@ -36,11 +53,6 @@ function earn ({ models }) {
Now: 100% of earnings go to zappers of the top 21% of posts/comments Now: 100% of earnings go to zappers of the top 21% of posts/comments
*/ */
if (sum <= 0) {
console.log('done', name, 'no earning')
return
}
// get earners { userId, id, type, rank, proportion } // get earners { userId, id, type, rank, proportion }
const earners = await models.$queryRawUnsafe(` const earners = await models.$queryRawUnsafe(`
WITH item_ratios AS ( WITH item_ratios AS (
@ -88,19 +100,21 @@ function earn ({ models }) {
const now = new Date(new Date().getTime()) const now = new Date(new Date().getTime())
// this is just a sanity check because it seems like a good idea // this is just a sanity check because it seems like a good idea
let total = 0n let total = 0
// for each earner, serialize earnings // for each earner, serialize earnings
// we do this for each earner because we don't need to serialize // we do this for each earner because we don't need to serialize
// all earner updates together // all earner updates together
earners.forEach(async earner => { earners.forEach(async earner => {
const earnings = BigInt(Math.floor(earner.proportion * sum)) const earnings = Math.floor(parseFloat(earner.proportion) * sum)
total += earnings total += earnings
if (total > sum) { if (total > sum) {
console.log('total exceeds sum', name) console.log(name, 'total exceeds sum', total, '>', sum)
return return
} }
console.log('stacker', earner.userId, 'earned', earnings)
if (earnings > 0) { if (earnings > 0) {
await serialize(models, await serialize(models,
models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings}, models.$executeRaw`SELECT earn(${earner.userId}::INTEGER, ${earnings},

View File

@ -60,7 +60,7 @@ function checkWithdrawal ({ boss, models, lnd }) {
const fee = Number(wdrwl.payment.fee_mtokens) const fee = Number(wdrwl.payment.fee_mtokens)
const paid = Number(wdrwl.payment.mtokens) - fee const paid = Number(wdrwl.payment.mtokens) - fee
await serialize(models, models.$executeRaw` await serialize(models, models.$executeRaw`
SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`) SELECT confirm_withdrawl(${id}::INTEGER, ${paid}, ${fee})`)
} else if (wdrwl?.is_failed || notFound) { } else if (wdrwl?.is_failed || notFound) {
let status = 'UNKNOWN_FAILURE' let status = 'UNKNOWN_FAILURE'
if (wdrwl?.failed.is_insufficient_balance) { if (wdrwl?.failed.is_insufficient_balance) {
@ -73,7 +73,7 @@ function checkWithdrawal ({ boss, models, lnd }) {
status = 'ROUTE_NOT_FOUND' status = 'ROUTE_NOT_FOUND'
} }
await serialize(models, models.$executeRaw` await serialize(models, models.$executeRaw`
SELECT reverse_withdrawl(${id}, ${status})`) SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`)
} else { } else {
// we need to requeue to check again in 5 seconds // we need to requeue to check again in 5 seconds
await boss.send('checkWithdrawal', { id, hash }, walletOptions) await boss.send('checkWithdrawal', { id, hash }, walletOptions)