Compare commits

...

5 Commits

Author SHA1 Message Date
Keyan 450c969dfc
allow edit of pending items (#1431) 2024-09-24 15:42:32 -05:00
ekzyis 1641b58e55
CSS animation for toasts and offcanvas (#1432)
* CSS animation for toasts

* Smaller toasts

* CSS animation for offcanvas

* faster animations and toast from the bottom

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-09-24 13:43:15 -05:00
k00b d7667f7820 add boost index 2024-09-24 13:10:10 -05:00
Keyan 76218dccac
batch zap requests (#1424) 2024-09-24 09:38:48 -05:00
ekzyis b82641d1bd
Include extension in S3 key (#1426) 2024-09-24 08:10:43 -05:00
12 changed files with 87 additions and 32 deletions

View File

@ -13,7 +13,13 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
// or more boost // or more boost
const old = await models.item.findUnique({ where: { id: parseInt(id) } }) const old = await models.item.findUnique({ where: { id: parseInt(id) } })
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me }) const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
return BigInt(totalFeesMsats) + satsToMsats(boost - old.boost) const cost = BigInt(totalFeesMsats) + satsToMsats(boost - old.boost)
if (cost > 0 && old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new Error('creation invoice not paid')
}
return cost
} }
export async function perform (args, context) { export async function perform (args, context) {

View File

@ -1328,10 +1328,6 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..
throw new GqlInputError('item is deleted') throw new GqlInputError('item is deleted')
} }
if (old.invoiceActionState && old.invoiceActionState !== 'PAID') {
throw new GqlInputError('cannot edit unpaid item')
}
// author can edit their own item (except anon) // author can edit their own item (except anon)
const meId = Number(me?.id ?? USER_ID.anon) const meId = Number(me?.id ?? USER_ID.anon)
const authorEdit = !!me && Number(old.userId) === meId const authorEdit = !!me && Number(old.userId) === meId

View File

@ -49,7 +49,10 @@ export default {
} }
const upload = await models.upload.create({ data: { ...fileParams } }) const upload = await models.upload.create({ data: { ...fileParams } })
return createPresignedPost({ key: String(upload.id), type, size })
const extension = type.split('/')[1]
const key = `${upload.id}.${extension}`
return createPresignedPost({ key, type, size })
} }
} }
} }

View File

@ -13,6 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction' import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo' import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form' import { BoostItemInput } from './adv-post-form'
import { useWallet } from '../wallets'
const defaultTips = [100, 1000, 10_000, 100_000] const defaultTips = [100, 1000, 10_000, 100_000]
@ -250,12 +251,12 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
} }
export function useZap () { export function useZap () {
const wallet = useWallet()
const act = useAct() const act = useAct()
const { me } = useMe()
const strike = useLightning() const strike = useLightning()
const toaster = useToast() const toaster = useToast()
return useCallback(async ({ item, abortSignal }) => { return useCallback(async ({ item, me, abortSignal }) => {
const meSats = (item?.meSats || 0) const meSats = (item?.meSats || 0)
// add current sats to next tip since idempotent zaps use desired total zap not difference // add current sats to next tip since idempotent zaps use desired total zap not difference
@ -267,7 +268,8 @@ export function useZap () {
try { try {
await abortSignal.pause({ me, amount: sats }) await abortSignal.pause({ me, amount: sats })
strike() strike()
const { error } = await act({ variables, optimisticResponse }) // batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
const { error } = await act({ variables, optimisticResponse, context: { batch: !!wallet || me?.privates?.sats > sats } })
if (error) throw error if (error) throw error
} catch (error) { } catch (error) {
if (error instanceof ActCanceledError) { if (error instanceof ActCanceledError) {
@ -277,7 +279,7 @@ export function useZap () {
const reason = error?.message || error?.toString?.() const reason = error?.message || error?.toString?.()
toaster.danger(reason) toaster.danger(reason)
} }
}, [act, me?.id, strike]) }, [act, toaster, strike, !!wallet])
} }
export class ActCanceledError extends Error { export class ActCanceledError extends Error {

View File

@ -65,6 +65,29 @@ export default function ItemInfo ({
const meSats = (me ? item.meSats : item.meAnonSats) || 0 const meSats = (me ? item.meSats : item.meAnonSats) || 0
const EditInfo = () => { const EditInfo = () => {
if (canEdit) {
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold'
onClick={() => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)}
>
<span>{editText || 'edit'} </span>
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID') &&
<Countdown
date={editThreshold}
onComplete={() => { setCanEdit(false) }}
/>}
</span>
</>
)
}
return null
}
const PaymentInfo = () => {
const waitForQrPayment = useQrPayment() const waitForQrPayment = useQrPayment()
if (item.deletedAt) return null if (item.deletedAt) return null
@ -90,18 +113,6 @@ export default function ItemInfo ({
) )
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error) onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
} }
} else if (canEdit) {
Component = () => (
<>
<span>{editText || 'edit'} </span>
<Countdown
date={editThreshold}
onComplete={() => {
setCanEdit(false)
}}
/>
</>)
onClick = () => onEdit ? onEdit() : router.push(`/items/${item.id}/edit`)
} else { } else {
return null return null
} }
@ -207,6 +218,7 @@ export default function ItemInfo ({
showActionDropdown && showActionDropdown &&
<> <>
<EditInfo /> <EditInfo />
<PaymentInfo />
<ActionDropdown> <ActionDropdown>
<CopyLinkDropdownItem item={item} /> <CopyLinkDropdownItem item={item} />
<InfoDropdownItem item={item} /> <InfoDropdownItem item={item} />

View File

@ -5,6 +5,7 @@ import Link from 'next/link'
import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common' import { LoginButtons, LogoutDropdownItem, NavWalletSummary } from '../common'
import AnonIcon from '@/svgs/spy-fill.svg' import AnonIcon from '@/svgs/spy-fill.svg'
import styles from './footer.module.css' import styles from './footer.module.css'
import canvasStyles from './offcanvas.module.css'
import classNames from 'classnames' import classNames from 'classnames'
export default function OffCanvas ({ me, dropNavKey }) { export default function OffCanvas ({ me, dropNavKey }) {
@ -28,7 +29,7 @@ export default function OffCanvas ({ me, dropNavKey }) {
<> <>
<MeImage onClick={handleShow} /> <MeImage onClick={handleShow} />
<Offcanvas style={{ maxWidth: '250px', zIndex: '10000' }} show={show} onHide={handleClose} placement='end'> <Offcanvas className={canvasStyles.offcanvas} show={show} onHide={handleClose} placement='end'>
<Offcanvas.Header closeButton> <Offcanvas.Header closeButton>
<Offcanvas.Title><NavWalletSummary /></Offcanvas.Title> <Offcanvas.Title><NavWalletSummary /></Offcanvas.Title>
</Offcanvas.Header> </Offcanvas.Header>

View File

@ -0,0 +1,15 @@
.offcanvas {
max-width: 250px;
z-index: 10000;
right: -100%;
animation: slide ease-out 0.2s;
}
@keyframes slide {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%)
}
}

View File

@ -1,13 +1,27 @@
.toastContainer { .toastContainer {
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
display: grid;
} }
.toast { .toast {
width: auto; font-size: small;
width: fit-content;
justify-self: right;
color: #fff; color: #fff;
bottom: -100%;
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
text-overflow: ellipsis; text-overflow: ellipsis;
animation: slide ease-out 0.2s;
}
@keyframes slide {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0%)
}
} }
.success { .success {
@ -70,9 +84,3 @@
.toastClose:hover { .toastClose:hover {
opacity: 0.7; opacity: 0.7;
} }
@media screen and (min-width: 400px) {
.toast {
width: var(--bs-toast-max-width);
}
}

View File

@ -1,4 +1,5 @@
import { ApolloClient, InMemoryCache, HttpLink, makeVar } from '@apollo/client' import { ApolloClient, InMemoryCache, HttpLink, makeVar, split } from '@apollo/client'
import { BatchHttpLink } from '@apollo/client/link/batch-http'
import { decodeCursor, LIMIT } from './cursor' import { decodeCursor, LIMIT } from './cursor'
import { SSR } from './constants' import { SSR } from './constants'
@ -28,8 +29,15 @@ export default function getApolloClient () {
export const meAnonSats = {} export const meAnonSats = {}
function getClient (uri) { function getClient (uri) {
const link = split(
// batch zaps if wallet is enabled so they can be executed serially in a single request
operation => operation.operationName === 'act' && operation.variables.act === 'TIP' && operation.getContext().batch,
new BatchHttpLink({ uri, batchInterval: 1000, batchDebounce: true, batchMax: 0, batchKey: op => op.variables.id }),
new HttpLink({ uri })
)
return new ApolloClient({ return new ApolloClient({
link: new HttpLink({ uri }), link,
ssrMode: SSR, ssrMode: SSR,
connectToDevTools: process.env.NODE_ENV !== 'production', connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({ cache: new InMemoryCache({

View File

@ -16,6 +16,7 @@ const apolloServer = new ApolloServer({
typeDefs, typeDefs,
resolvers, resolvers,
introspection: true, introspection: true,
allowBatchedHttpRequests: true,
plugins: [{ plugins: [{
requestDidStart (initialRequestContext) { requestDidStart (initialRequestContext) {
return { return {

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Item_boost_idx" ON "Item"("boost");

View File

@ -532,6 +532,7 @@ model Item {
@@index([invoiceActionState]) @@index([invoiceActionState])
@@index([cost]) @@index([cost])
@@index([url]) @@index([url])
@@index([boost])
} }
// we use this to denormalize a user's aggregated interactions (zaps) with an item // we use this to denormalize a user's aggregated interactions (zaps) with an item