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
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
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;
access_log /var/log/nginx/access.log main;
client_header_timeout 60;
client_body_timeout 60;
keepalive_timeout 60;
client_header_timeout 90;
client_body_timeout 90;
keepalive_timeout 90;
gzip on;
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;

View File

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

View File

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

View File

@ -35,17 +35,17 @@ async function serialize (models, ...calls) {
if (error.message.includes('SN_REVOKED_OR_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')) {
bail(new Error('too many pending invoices'))
}
if (error.message.includes('SN_INV_EXCEED_BALANCE')) {
bail(new Error('pending invoices must not cause balance to exceed 1m sats'))
}
if (error.message.includes('40001')) {
throw new Error('wallet balance serialization failure - retry again')
if (error.message.includes('40001') || error.code === 'P2034') {
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)
}

View File

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

View File

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

View File

@ -89,7 +89,7 @@ export function WhenAreaChart ({ data }) {
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 />
{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]} />)}
@ -124,7 +124,7 @@ export function WhenLineChart ({ data }) {
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 />
{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]} />)}
@ -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='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 />
{barNames?.map((v, i) =>
<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)) {
// HACK wait for other comments to collapse if they're collapsed
setTimeout(() => {
ref.current.scrollIntoView()
ref.current.classList.add('flash-it')
router.replace({
pathname: router.pathname,
query: { id: router.query.id }
}, undefined, { scroll: false })
ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
ref.current.classList.add('outline-it')
}, 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 op = root.user.name === item.user.name
@ -131,6 +135,8 @@ export default function Comment ({
return (
<div
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}`}>
{item.meDontLike

View File

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

View File

@ -1,5 +1,54 @@
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 () {
const [dark, setDark] = useState()

View File

@ -5,7 +5,7 @@ import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
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'
function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
@ -48,7 +48,7 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton,
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: 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 formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0

View File

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

View File

@ -1,6 +1,7 @@
import { gql, useQuery } from '@apollo/client'
import Link from 'next/link'
import { RewardLine } from '../pages/rewards'
import { SSR } from '../lib/constants'
const REWARDS = gql`
{
@ -10,7 +11,7 @@ const REWARDS = gql`
}`
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
return (

View File

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

View File

@ -72,7 +72,7 @@ export function InputSkeleton ({ label, hint }) {
return (
<BootstrapForm.Group>
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
<div className='form-control clouds' />
<div className='form-control clouds' style={{ color: 'transparent' }}>.</div>
{hint &&
<BootstrapForm.Text>
{hint}
@ -431,7 +431,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra,
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>
{extra &&
<div className={styles.checkboxExtra}>

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import Badge from 'react-bootstrap/Badge'
import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown'
import { abbrNum } from '../lib/format'
import { newComments } from '../lib/new-comments'
import { newComments, commentsViewedAt } from '../lib/new-comments'
import { timeSince } from '../lib/time'
import CowboyHat from './cowboy-hat'
import { DeleteDropdownItem } from './delete'
@ -48,9 +48,22 @@ export default function ItemInfo ({ item, pendingSats, full, commentsText, class
<span>{abbrNum(item.boost)} boost</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'}
{hasNewComments && <>{' '}<Badge className={styles.newComment} bg={null}>new</Badge></>}
{hasNewComments &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Link>
<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} />
</Link>
<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))}
</Link>
</span>

View File

@ -12,6 +12,8 @@ import Flag from '../svgs/flag-fill.svg'
import ImageIcon from '../svgs/image-fill.svg'
import { abbrNum } from '../lib/format'
import ItemInfo from './item-info'
import { commentsViewedAt } from '../lib/new-comments'
import { useRouter } from 'next/router'
export function SearchTitle ({ title }) {
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 }) {
const titleRef = useRef()
const router = useRouter()
const [pendingSats, setPendingSats] = useState(0)
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} />}
<div className={styles.hunk}>
<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.pollCost && <span className={styles.icon}> <PollIcon className='fill-grey ms-1' height={14} width={14} /></span>}
{item.bounty > 0 &&

View File

@ -7,6 +7,15 @@
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 {
display: inline-block;
}

View File

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

View File

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

View File

@ -43,7 +43,7 @@ const authErrorMessages = {
default: 'Auth failed. Try again or choose a different method.'
}
export function authErrorMessage(error) {
export function authErrorMessage (error) {
return error && (authErrorMessages[error] ?? authErrorMessages.default)
}
@ -85,10 +85,13 @@ export default function Login ({ providers, callbackUrl, error, text, Header, Fo
className={`mt-2 ${styles.providerButton}`}
key={provider.id}
type={provider.id.toLowerCase()}
onClick={() => router.push({
onClick={() => {
const { nodata, ...query } = router.query
router.push({
pathname: router.pathname,
query: { callbackUrl: router.query.callbackUrl, type: provider.name.toLowerCase() }
})}
query: { ...query, type: provider.name.toLowerCase() }
})
}}
text={`${text || 'Login'} with`}
/>
)

View File

@ -1,13 +1,14 @@
import React, { useContext } from 'react'
import { useQuery } from '@apollo/client'
import { ME } from '../fragments/users'
import { SSR } from '../lib/constants'
export const MeContext = React.createContext({
me: null
})
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 = {
me: data?.me || me

View File

@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react'
import { useApolloClient, useQuery } from '@apollo/client'
import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import { NOTIFICATIONS } from '../fragments/notifications'
import MoreFooter from './more-footer'
import Invite from './invite'
import { ignoreClick } from '../lib/clicks'
@ -20,29 +20,47 @@ import styles from './notifications.module.css'
import { useServiceWorker } from './serviceworker'
import { Checkbox, Form } from './form'
import { useRouter } from 'next/router'
import { useData } from './use-data'
function Notification ({ n }) {
switch (n.__typename) {
case 'Earn': return <EarnNotification n={n} />
case 'Invitification': return <Invitification n={n} />
case 'InvoicePaid': return <InvoicePaid n={n} />
case 'Referral': return <Referral n={n} />
case 'Streak': return <Streak n={n} />
case 'Votification': return <Votification n={n} />
case 'Mention': return <Mention n={n} />
case 'JobChanged': return <JobChanged n={n} />
case 'Reply': return <Reply n={n} />
function Notification ({ n, fresh }) {
const type = n.__typename
return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Invitification' && <Invitification n={n} />) ||
(type === 'InvoicePaid' && <InvoicePaid n={n} />) ||
(type === 'Referral' && <Referral 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)
return null
</NotificationLayout>
)
}
function NotificationLayout ({ children, href, as }) {
function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
return (
<div
className='clickToContext'
onClick={(e) => !ignoreClick(e) && router.push(href, as)}
className={
`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}
</div>
@ -50,6 +68,14 @@ function NotificationLayout ({ children, href, as }) {
}
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) {
const path = n.item.path.split('.')
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)' }} />
<div className='ms-2'>
<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>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
@ -145,7 +171,7 @@ function EarnNotification ({ n }) {
function Invitification ({ n }) {
return (
<NotificationLayout href='/invites'>
<>
<small className='fw-bold text-secondary ms-2'>
your invite has been redeemed by {n.invite.invitees.length} stackers
</small>
@ -157,35 +183,31 @@ function Invitification ({ n }) {
}
/>
</div>
</NotificationLayout>
</>
)
}
function InvoicePaid ({ n }) {
return (
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
<div className='fw-bold text-info ms-2 py-1'>
<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>
</NotificationLayout>
)
}
function Referral ({ n }) {
return (
<NotificationLayout>
<small className='fw-bold text-secondary ms-2'>
someone joined via one of your <Link href='/referrals/month' className='text-reset'>referral links</Link>
<small className='text-muted ms-1 fw-normal'>{timeSince(new Date(n.sortTime))}</small>
someone joined via one of your referral links
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
</NotificationLayout>
)
}
function Votification ({ n }) {
return (
<NotificationLayout {...defaultOnClick(n)}>
<>
<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}`}
</small>
@ -200,13 +222,13 @@ function Votification ({ n }) {
</div>
)}
</div>
</NotificationLayout>
</>
)
}
function Mention ({ n }) {
return (
<NotificationLayout {...defaultOnClick(n)}>
<>
<small className='fw-bold text-info ms-2'>
you were mentioned in
</small>
@ -220,13 +242,13 @@ function Mention ({ n }) {
</RootProvider>
</div>)}
</div>
</NotificationLayout>
</>
)
}
function JobChanged ({ n }) {
return (
<NotificationLayout {...defaultOnClick(n)}>
<>
<small className={`fw-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ms-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
@ -235,13 +257,12 @@ function JobChanged ({ n }) {
: 'your job has been stopped')}
</small>
<ItemJob item={n.item} />
</NotificationLayout>
</>
)
}
function Reply ({ n }) {
return (
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
<div className='py-2'>
{n.item.title
? <Item item={n.item} />
@ -253,11 +274,10 @@ function Reply ({ n }) {
</div>
)}
</div>
</NotificationLayout>
)
}
function NotificationAlert () {
export function NotificationAlert () {
const [showAlert, setShowAlert] = useState(false)
const [hasSubscription, setHasSubscription] = useState(false)
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 }) {
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(() => {
client.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: false
if (lastChecked && !router?.query?.checkedAt) {
router.replace({
pathname: router.pathname,
query: {
...router.query,
nodata: true, // make sure nodata is set so we don't fetch on back/forward
checkedAt: lastChecked
}
})
}, [client])
}, router.asPath, { ...router.options, shallow: true })
}
}, [router, lastChecked])
const { notifications: { notifications, earn, lastChecked, cursor } } = useMemo(() => {
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 />
if (!dat) return <CommentsFlatSkeleton />
return (
<>
<NotificationAlert />
<div className='fresh'>
{earn && <Notification n={earn} key='earn' />}
{fresh.map((n, i) => (
<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} />
))}
{notifications.map(n =>
<Notification
n={n} key={nid(n)}
fresh={new Date(n.sortTime) > new Date(router?.query?.checkedAt)}
/>)}
<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 {
background-color: rgba(0, 0, 0, 0.03);
border-radius: .4rem;
background-color: rgba(128, 128, 128, 0.1);
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 {

View File

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

View File

@ -16,7 +16,7 @@
.searchSection.solid {
pointer-events: auto;
background: var(--theme-body);
background: var(--bs-body-bg);
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 by = router.query.by || ''
const by = router.query.by || (what === 'stackers' ? 'stacked' : 'votes')
const when = router.query.when || ''
return (

View File

@ -27,7 +27,7 @@
}
.cover {
background: var(--theme-body);
background: var(--bs-body-bg);
width: 100%;
overflow: hidden;
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 { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
import { useData } from './use-data'
// all of this nonsense is to show the stat we are sorting by first
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 }) {
const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
useEffect(() => {
if (variables?.by) {
// shift the stat we are sorting by to the front
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])
const { users, cursor } = useMemo(() => {
if (!data && !ssrData) return {}
if (!dat) return {}
if (destructureData) {
return destructureData(data || ssrData)
return destructureData(dat)
} else {
return data || ssrData
return dat
}
}, [data, ssrData])
}, [dat])
if (!ssrData && !data) {
if (!dat) {
return <UsersSkeleton />
}

View File

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

View File

@ -45,3 +45,6 @@ module.exports = {
ANON_POST_FEE: 1000,
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)
}
export function commentsViewedAt (item) {
return window.localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`)
}
export function newComments (item) {
if (!item.parentId) {
const commentsViewedAt = window.localStorage.getItem(`${COMMENTS_VIEW_PREFIX}:${item.id}`)
const commentsViewNum = window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`)
const viewedAt = commentsViewedAt(item)
const viewNum = window.localStorage.getItem(`${COMMENTS_NUM_PREFIX}:${item.id}`)
if (commentsViewedAt && commentsViewNum) {
return commentsViewedAt < new Date(item.lastCommentAt).getTime() || commentsViewNum < item.ncomments
if (viewedAt && viewNum) {
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*',
headers: corsHeaders
},
{
source: '/dark.js',
headers: [
...corsHeaders
]
},
{
source: '/.well-known/:slug*',
headers: [
@ -120,6 +114,10 @@ module.exports = withPlausibleProxy()({
source: '/.well-known/web-app-origin-association',
destination: '/api/web-app-origin-association'
},
{
source: '/~:sub/:slug*\\?:query*',
destination: '/~/:slug*?:query*&sub=:sub'
},
{
source: '/~:sub/:slug*',
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",
"build": "next build",
"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": {
"@apollo/client": "^3.7.17",
"@apollo/server": "^4.8.1",
"@apollo/server": "^4.9.0",
"@as-integrations/next": "^2.0.1",
"@auth/prisma-adapter": "^1.0.1",
"@graphql-tools/schema": "^10.0.0",
@ -22,12 +22,11 @@
"acorn": "^8.10.0",
"ajv": "^8.12.0",
"async-retry": "^1.3.1",
"aws-sdk": "^2.1422.0",
"aws-sdk": "^2.1425.0",
"babel-plugin-inline-react-svg": "^2.0.2",
"bech32": "^2.0.0",
"bolt11": "^1.4.1",
"bootstrap": "^5.3.0",
"browserslist": "^4.21.4",
"bootstrap": "^5.3.1",
"canonical-json": "0.0.4",
"clipboard-copy": "^4.0.1",
"cross-fetch": "^4.0.0",
@ -45,18 +44,19 @@
"mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^13.4.12",
"next": "^13.4.13-canary.12",
"next-auth": "^4.22.3",
"next-plausible": "^3.10.1",
"next-seo": "^6.1.0",
"nextjs-progressbar": "0.0.16",
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.4",
"nostr": "^0.2.8",
"nprogress": "^0.2.0",
"opentimestamps": "^0.4.9",
"page-metadata-parser": "^1.1.4",
"pageres": "^7.1.0",
"pg-boss": "^9.0.3",
"prisma": "^5.0.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.0",
@ -75,11 +75,11 @@
"remark-gfm": "^3.0.1",
"remove-markdown": "^0.5.0",
"sass": "^1.64.1",
"tldts": "^6.0.12",
"tldts": "^6.0.13",
"typescript": "^5.1.6",
"unist-util-visit": "^5.0.0",
"url-unshort": "^6.1.0",
"web-push": "^3.6.2",
"web-push": "^3.6.4",
"webln": "^0.3.2",
"webpack": "^5.88.2",
"workbox-navigation-preload": "^7.0.0",
@ -92,7 +92,7 @@
"yup": "^1.2.0"
},
"engines": {
"node": "18.16.1"
"node": "18.17.0"
},
"standard": {
"parser": "@babel/eslint-parser",
@ -107,8 +107,7 @@
"@babel/core": "^7.22.9",
"@babel/eslint-parser": "^7.22.9",
"@next/eslint-plugin-next": "^13.4.12",
"eslint": "^8.45.0",
"prisma": "^5.0.0",
"eslint": "^8.46.0",
"standard": "^17.1.0"
}
}

View File

@ -3,7 +3,6 @@ import { ApolloProvider, gql } from '@apollo/client'
import { MeProvider } from '../components/me'
import PlausibleProvider from 'next-plausible'
import getApolloClient from '../lib/apollo'
import NextNProgress from 'nextjs-progressbar'
import { PriceProvider } from '../components/price'
import Head from 'next/head'
import { useRouter } from 'next/dist/client/router'
@ -12,8 +11,13 @@ import { ShowModalProvider } from '../components/modal'
import ErrorBoundary from '../components/error-boundary'
import { LightningProvider } from '../components/lightning'
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) {
if (apollo && data) {
@ -21,8 +25,8 @@ function writeQuery (client, apollo, data) {
query: gql`${apollo.query}`,
data,
variables: apollo.variables,
broadcast: !SSR,
overwrite: SSR
overwrite: SSR,
broadcast: false
})
}
}
@ -32,48 +36,49 @@ function MyApp ({ Component, pageProps: { ...props } }) {
const router = useRouter()
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
// So every page load, we modify the route in browser history
// 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
// 1. prevent data from reloading and 2. perserve scroll
// (2) is not possible while intercepting nav with beforePopState
if (router.query.nodata) return
router.replace({
pathname: router.pathname,
query: { ...router.query, nodata: true }
}, router.asPath, { ...router.options, shallow: true }).catch((e) => {
// workaround for https://github.com/vercel/next.js/issues/37362
if (!e.cancelled) {
console.log(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
ssr data
*/
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(() => {
writeQuery(client, apollo, ssrData)
}, [client, apollo, ssrData])
return (
<>
<NextNProgress
color='var(--bs-primary)'
startPosition={0.3}
stopDelayMs={200}
height={2}
showOnShallow={false}
options={{ showSpinner: false }}
/>
<Head>
<meta name='viewport' content='initial-scale=1.0, width=device-width' />
</Head>

View File

@ -23,8 +23,43 @@ class MyDocument extends Document {
}}
/>
<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' />
<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: 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' />
@ -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: 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' />
<Script src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/dark.js`} crossOrigin='' strategy='beforeInteractive' type='module' />
</Head>
<body>
<Main />

View File

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

View File

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

View File

@ -47,7 +47,7 @@ export default async ({ query: { username, amount, nostr } }, res) => {
await serialize(models,
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({
pr: invoice.request,

View File

@ -8,10 +8,10 @@ import { INVITE_FIELDS } from '../../fragments/invites'
import getSSRApolloClient from '../../api/ssrApollo'
import Link from 'next/link'
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 } }) {
const session = await getServerSession(req, res, authOptions(req))
const session = await getServerSession(req, res, getAuthOptions(req))
const client = await getSSRApolloClient({ req, res })
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 Invite from '../../components/invite'
import { inviteSchema } from '../../lib/validate'
import { SSR } from '../../lib/constants'
function InviteForm () {
const [createInvite] = useMutation(
@ -93,7 +94,7 @@ export default function Invites () {
...InviteFields
}
}
`, { fetchPolicy: 'cache-and-network' })
`, SSR ? {} : { fetchPolicy: 'cache-and-network' })
const [active, inactive] = data && data.invites
? data.invites.reduce((result, invite) => {

View File

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

View File

@ -37,7 +37,7 @@ function Ots ({ item }) {
: (
<pre
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}
</pre>)}
<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 Layout from '../components/layout'
import Notifications from '../components/notifications'
import { NOTIFICATIONS } from '../fragments/notifications'
import Notifications, { NotificationAlert } from '../components/notifications'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import { useApolloClient } from '@apollo/client'
export const getServerSideProps = getGetServerSideProps(NOTIFICATIONS)
export default function NotificationPage ({ ssrData }) {
const client = useApolloClient()
useEffect(() => {
client?.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: false
}
})
}, [])
return (
<Layout>
<NotificationAlert />
<Notifications ssrData={ssrData} />
</Layout>
)

View File

@ -1,5 +1,5 @@
import { gql } from 'graphql-tag'
import { useEffect, useState } from 'react'
import { useMemo } from 'react'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import { getGetServerSideProps } from '../api/ssrApollo'
@ -13,6 +13,7 @@ import { abbrNum } from '../lib/format'
import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import dynamic from 'next/dynamic'
import { SSR } from '../lib/constants'
const GrowthPieChart = dynamic(() => import('../components/charts').then(mod => mod.GrowthPieChart), {
loading: () => <div>Loading...</div>
@ -47,11 +48,7 @@ function midnight (tz) {
export const getServerSideProps = getGetServerSideProps(REWARDS)
export function RewardLine ({ total }) {
const [threshold, setThreshold] = useState(0)
useEffect(() => {
setThreshold(midnight('America/Chicago'))
}, [])
const threshold = useMemo(() => midnight('America/Chicago'))
return (
<>
@ -59,14 +56,14 @@ export function RewardLine ({ total }) {
{threshold &&
<Countdown
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 }) {
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 />
const { expectedRewards: { total, sources } } = data || ssrData

View File

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

View File

@ -245,7 +245,7 @@ export default function Settings ({ ssrData }) {
name='greeterMode'
/>
<AccordianItem
headerColor='var(--theme-color)'
headerColor='var(--bs-body-color)'
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>}
body={
@ -372,14 +372,19 @@ function AuthMethods ({ methods }) {
return (
<>
<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
router.replace({
pathname,
query: { nodata, ...rest }
}, { pathname, query: { ...rest } }, { shallow: true })
setErr(undefined)
}} dismissible>{err}</Alert>}
}} dismissible
>{err}
</Alert>
)}
{providers?.map(provider => {
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 { getGetServerSideProps } from '../api/ssrApollo'
import { amountSchema, lnAddrSchema, withdrawlSchema } from '../lib/validate'
import { SSR } from '../lib/constants'
export const getServerSideProps = getGetServerSideProps()
@ -210,7 +211,7 @@ function LnQRWith ({ k1, encodedUrl }) {
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) {
router.push(`/withdrawals/${data.lnWith.withdrawalId}`)

View File

@ -6,6 +6,7 @@ import InvoiceStatus from '../../components/invoice-status'
import { useRouter } from 'next/router'
import { WITHDRAWL } from '../../fragments/wallet'
import Link from 'next/link'
import { SSR } from '../../lib/constants'
export default function Withdrawl () {
return (
@ -31,7 +32,9 @@ export function WithdrawlSkeleton ({ status }) {
function LoadWithdrawl () {
const router = useRouter()
const { loading, error, data } = useQuery(WITHDRAWL, {
const { loading, error, data } = useQuery(WITHDRAWL, SSR
? {}
: {
variables: { id: router.query.id },
pollInterval: 1000,
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?
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)
@@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",
"orientation": "any",
"theme_color": "#000000",
"theme_color": "#121214",
"background_color": "#FADA5E",
"id": "/",
"start_url": "/",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -23,7 +23,10 @@ $theme-colors: (
"grey-darkmode": #8c8c8c,
);
$body-bg: #f5f5f7;
$body-bg: #fcfcff;
$body-bg-dark: #121214;
$body-color: #212529;
$body-color-dark: #f0f0f0;
$border-radius: .4rem;
$enable-transitions: false;
$enable-gradients: false;
@ -41,6 +44,10 @@ $btn-font-weight: bold;
$btn-focus-width: 0;
$btn-border-width: 0;
$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;
$close-text-shadow: none;
$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;
$form-check-input-checked-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;
$tooltip-bg: #5c8001;
$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-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-size: 1.5rem;
$popover-body-padding-y: .5rem;
@ -81,6 +89,49 @@ $popover-max-width: 320px !default;
$popover-border-color: var(--theme-borderColor);
$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';
@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 {
display: none;
}
@ -145,7 +205,7 @@ dl {
mark {
background-color: #fada5e5e;
padding: 0 0.2rem;
color: var(--theme-color);
color: var(--bs-body-color);
}
.table-sm th,
@ -172,8 +232,8 @@ mark {
}
.table {
color: var(--theme-color);
background-color: var(--theme-body);
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
}
.table th,
@ -183,13 +243,13 @@ mark {
}
.table-hover tbody tr:hover {
color: var(--theme-color);
color: var(--bs-body-color);
background-color: var(--theme-clickToContextColor);
}
html, body {
background: var(--theme-body);
color: var(--theme-color);
background: var(--bs-body-bg) !important;
color: var(--bs-body-color) !important;
min-height: 100vh;
min-height: 100svh;
}
@ -214,7 +274,7 @@ select,
div[contenteditable],
.form-control {
background-color: var(--theme-inputBg);
color: var(--theme-color);
color: var(--bs-body-color);
border-color: var(--theme-borderColor);
}
@ -256,7 +316,7 @@ select:focus {
div[contenteditable]:focus,
.form-control:focus {
background-color: var(--theme-inputBg);
color: var(--theme-color);
color: var(--bs-body-color);
outline: 0;
box-shadow: 0 0 0 0.2rem rgb(250 218 94 / 25%);
}
@ -297,11 +357,6 @@ div[contenteditable]:disabled,
fill: #212529;
}
.fresh {
background-color: var(--theme-clickToContextColor);
border-radius: .4rem;
}
.modal-content {
background-color: var(--theme-inputBg);
border-color: var(--theme-borderColor);
@ -406,7 +461,7 @@ footer {
.input-group-text {
background-color: var(--theme-clickToContextColor);
border-color: var(--theme-borderColor);
color: var(--theme-color);
color: var(--bs-body-color);
}
textarea.form-control,
@ -445,8 +500,8 @@ div[contenteditable] {
}
.popover {
color: var(--theme-color);
background-color: var(--theme-body);
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border-color: var(--theme-borderColor);
}
@ -455,7 +510,7 @@ div[contenteditable] {
}
.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: var(--theme-color);
fill: var(--bs-body-color);
}
.fill-warning {
@ -617,6 +672,34 @@ div[contenteditable]:focus,
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 {
0% {
transform: rotate(0deg);
@ -679,14 +762,15 @@ div[contenteditable]:focus,
.tooltip-inner {
padding: 0.1rem 0.3rem;
color: #fff;
}
.popover {
.popover-header {
background-color: var(--theme-body);
color: var(--theme-color);
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
}
.popover-body {
color: var(--theme-color);
color: var(--bs-body-color);
}
}

View File

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

View File

@ -9,7 +9,7 @@ function earn ({ models }) {
console.log('running', name)
// 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
FROM "ItemAct"
JOIN "Item" ON "ItemAct"."itemId" = "Item".id
@ -21,7 +21,24 @@ function earn ({ models }) {
SELECT coalesce(sum(sats), 0) as sum
FROM "Donation"
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:
@ -36,11 +53,6 @@ function earn ({ models }) {
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 }
const earners = await models.$queryRawUnsafe(`
WITH item_ratios AS (
@ -88,19 +100,21 @@ function earn ({ models }) {
const now = new Date(new Date().getTime())
// this is just a sanity check because it seems like a good idea
let total = 0n
let total = 0
// for each earner, serialize earnings
// we do this for each earner because we don't need to serialize
// all earner updates together
earners.forEach(async earner => {
const earnings = BigInt(Math.floor(earner.proportion * sum))
const earnings = Math.floor(parseFloat(earner.proportion) * sum)
total += earnings
if (total > sum) {
console.log('total exceeds sum', name)
console.log(name, 'total exceeds sum', total, '>', sum)
return
}
console.log('stacker', earner.userId, 'earned', earnings)
if (earnings > 0) {
await serialize(models,
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 paid = Number(wdrwl.payment.mtokens) - fee
await serialize(models, models.$executeRaw`
SELECT confirm_withdrawl(${id}, ${paid}, ${fee})`)
SELECT confirm_withdrawl(${id}::INTEGER, ${paid}, ${fee})`)
} else if (wdrwl?.is_failed || notFound) {
let status = 'UNKNOWN_FAILURE'
if (wdrwl?.failed.is_insufficient_balance) {
@ -73,7 +73,7 @@ function checkWithdrawal ({ boss, models, lnd }) {
status = 'ROUTE_NOT_FOUND'
}
await serialize(models, models.$executeRaw`
SELECT reverse_withdrawl(${id}, ${status})`)
SELECT reverse_withdrawl(${id}::INTEGER, ${status}::"WithdrawlStatus")`)
} else {
// we need to requeue to check again in 5 seconds
await boss.send('checkWithdrawal', { id, hash }, walletOptions)