stacker.news/components/user-header.js
ekzyis 8f590425dc
Image uploads (#576)
* Add icon to add images

* Open file explorer to select image

* Upload images to S3 on selection

* Show uploaded images below text input

* Link and remove image

* Fetch unsubmitted images from database

* Mark S3 images as submitted in imgproxy job

* Add margin-top

* Mark images as submitted on client after successful mutation

* Also delete objects in S3

* Allow items to have multiple uploads linked

* Overwrite old avatar

* Add fees for presigned URLs

* Use Github style upload

* removed upfront fees
* removed images provider since we no longer need to keep track of unsubmitted images on the client
* removed User.images resolver
* removed deleteImage mutation
* use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload

* Add Upload.paid boolean column

One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation).

Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for.

This makes fee calculation easier since no JOINs are required.

* Add image fees during item creation/update

* we calculate image fees during item creation and update now
* function imageFees returns queries which deduct fees from user and mark images as paid + fees
* queries need to be run inside same transaction as item creation/update

* Allow anons to get presigned URLs

* Add comments regarding avatar upload

* Use megabytes in error message

* Remove unnecessary avatar check during image fees calculation

* Show image fees in frontend

* Also update image fees on blur

This makes sure that the images fees reflect the current state. For example, if an image was removed.

We could also add debounced requests.

* Show amount of unpaid images in receipt

* Fix fees in sats deducted from msats

* Fix algebraic order of fees

Spam fees must come immediately after the base fee since it multiplies the base fee.

* Fix image fees in edit receipt

* Fix stale fees shown

If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet.

* Add 0 base fee in edit receipt

* Remove 's' from 'image fees' in receipts

* Remove unnecessary async

* Remove 'Uploading <name>...' from text input on error

* Support upload of multiple files at once

* Add schedule to delete unused images

* Fix image fee display in receipts

* Use Drag and Drop API for image upload

* Remove dragOver style on drop

* Increase max upload size to 10MB to allow HQ camera pictures

* Fix free upload quota

* Fix stale image fees served

* Fix bad image fee return statements

* Fix multiplication with feesPerImage

* Fix NULL returned for size24h, sizeNow

* Remove unnecessary text field in query

* refactor: Unify <ImageUpload> and <Upload> component

* Add avatar cache busting using random query param

* Calculate image fee info in postgres function

* we now calculate image fee info in a postgres function which is much cleaner
* we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item!
* reversed changes in `serializeInvoiceable`

* Fix line break in receipt

* Update upload limits

* Add comment about `e.target.value = null`

* Use debounce instead of onBlur to update image fees info

* Fix invoice amount

* Refactor avatar upload control flow

* Update image fees in onChange

* Fix rescheduling of other jobs

* also update schedule from every minute to every hour

* Add image fees in calling context

* keep item ids on uploads

* Fix incompatible onSubmit signature

* Revert "keep item ids on uploads"

This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4.

* many2many item uploads

* pretty subdomain for images

* handle upload conditions for profile images and job logos

---------

Co-authored-by: ekzyis <ek@ekzyis.com>
Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 14:53:33 -06:00

242 lines
7.7 KiB
JavaScript

import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import Image from 'react-bootstrap/Image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Nav from 'react-bootstrap/Nav'
import { useState } from 'react'
import { Form, Input, SubmitButton } from './form'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import styles from './user-header.module.css'
import { useMe } from './me'
import { NAME_MUTATION } from '../fragments/users'
import QRCode from 'qrcode.react'
import LightningIcon from '../svgs/bolt.svg'
import { encodeLNUrl } from '../lib/lnurl'
import Avatar from './avatar'
import { userSchema } from '../lib/validate'
import { useShowModal } from './modal'
import { numWithUnits } from '../lib/format'
import Hat from './hat'
import SubscribeUserDropdownItem from './subscribeUser'
import ActionDropdown from './action-dropdown'
import CodeIcon from '../svgs/terminal-box-fill.svg'
import MuteDropdownItem from './mute'
import copy from 'clipboard-copy'
import { useToast } from './toast'
export default function UserHeader ({ user }) {
const router = useRouter()
return (
<>
<HeaderHeader user={user} />
<Nav
className={styles.nav}
activeKey={!!router.asPath.split('/')[2]}
>
<Nav.Item>
<Link href={'/' + user.name} passHref legacyBehavior>
<Nav.Link eventKey={false}>bio</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={'/' + user.name + '/all'} passHref legacyBehavior>
<Nav.Link eventKey>
{numWithUnits(user.nitems, {
abbreviate: false,
unitSingular: 'item',
unitPlural: 'items'
})}
</Nav.Link>
</Link>
</Nav.Item>
</Nav>
</>
)
}
function HeaderPhoto ({ user, isMe }) {
const [setPhoto] = useMutation(
gql`
mutation setPhoto($photoId: ID!) {
setPhoto(photoId: $photoId)
}`, {
update (cache, { data: { setPhoto } }) {
cache.modify({
id: `User:${user.id}`,
fields: {
photoId () {
return setPhoto
}
}
})
},
onCompleted ({ setPhoto: photoId }) {
const src = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}`
setSrc(src)
}
}
)
const initialSrc = user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'
const [src, setSrc] = useState(initialSrc)
return (
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
<Image
src={src} width='135' height='135'
className={styles.userimg}
/>
{isMe &&
<Avatar onSuccess={async photoId => {
const { error } = await setPhoto({ variables: { photoId } })
if (error) {
console.log(error)
}
}}
/>}
</div>
)
}
function NymEdit ({ user, setEditting }) {
const router = useRouter()
const [setName] = useMutation(NAME_MUTATION, {
update (cache, { data: { setName } }) {
cache.modify({
id: `User:${user.id}`,
fields: {
name () {
return setName
}
}
})
}
})
const client = useApolloClient()
const schema = userSchema({ client })
return (
<Form
schema={schema}
initial={{
name: user.name
}}
validateImmediately
validateOnChange={false}
onSubmit={async ({ name }) => {
if (name === user.name) {
setEditting(false)
return
}
const { error } = await setName({ variables: { name } })
if (error) {
throw new Error({ message: error.toString() })
}
setEditting(false)
// navigate to new name
const { nodata, ...query } = router.query
router.replace({
pathname: router.pathname,
query: { ...query, name }
}, undefined, { shallow: true })
}}
>
<div className='d-flex align-items-center mb-2'>
<Input
prepend=<InputGroup.Text>@</InputGroup.Text>
name='name'
autoFocus
groupClassName={styles.usernameForm}
showValid
debounce={500}
/>
<SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
</div>
</Form>
)
}
function NymView ({ user, isMe, setEditting }) {
const me = useMe()
return (
<div className='d-flex align-items-center mb-2'>
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
{isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
{!isMe && me &&
<div className='ms-2'>
<ActionDropdown>
<SubscribeUserDropdownItem user={user} target='posts' />
<SubscribeUserDropdownItem user={user} target='comments' />
<MuteDropdownItem user={user} />
</ActionDropdown>
</div>}
</div>
)
}
function HeaderNym ({ user, isMe }) {
const [editting, setEditting] = useState(false)
return editting
? <NymEdit user={user} setEditting={setEditting} />
: <NymView user={user} isMe={isMe} setEditting={setEditting} />
}
function HeaderHeader ({ user }) {
const me = useMe()
const showModal = useShowModal()
const toaster = useToast()
const isMe = me?.name === user.name
const Satistics = () => (
<div className={`mb-2 ms-0 ms-sm-1 ${styles.username} text-success`}>
{numWithUnits(user.stacked, { abbreviate: false, format: true })} stacked
</div>)
const lnurlp = encodeLNUrl(new URL(`https://stacker.news/.well-known/lnurlp/${user.name}`))
return (
<div className='d-flex mt-2 flex-wrap flex-column flex-sm-row'>
<HeaderPhoto user={user} isMe={isMe} />
<div className='ms-0 ms-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
<HeaderNym user={user} isMe={isMe} />
<Satistics user={user} />
<Button
className='fw-bold ms-0' onClick={() => {
copy(`${user.name}@stacker.news`)
.then(() => {
toaster.success(`copied ${user.name}@stacker.news to clipboard`)
}).catch(() => {
toaster.error(`failed to copy ${user.name}@stacker.news to clipboard`)
})
showModal(({ onClose }) => (
<>
<a className='d-flex m-auto p-3' style={{ background: 'white', maxWidth: 'fit-content' }} href={`lightning:${lnurlp}`}>
<QRCode className='d-flex m-auto' value={lnurlp} renderAs='svg' size={300} />
</a>
<div className='text-center fw-bold text-muted mt-3'>click or scan</div>
</>
))
}}
>
<LightningIcon
width={20}
height={20}
className='me-1'
/>{user.name}@stacker.news
</Button>
<div className='d-flex flex-column mt-1 ms-0'>
<small className='text-muted d-flex-inline'>stacking since: {user.since
? <Link href={`/items/${user.since}`} className='ms-1'>#{user.since}</Link>
: <span>never</span>}
</small>
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
{user.isContributor && <small className='text-muted d-flex align-items-center'><CodeIcon className='me-1' height={16} width={16} /> verified stacker.news contributor</small>}
</div>
</div>
</div>
)
}