feat: implemented zap split UI
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
All checks were successful
Release to Staging / build_and_release (push) Successful in 48s
This commit is contained in:
parent
3ed7eada83
commit
bad1404e1a
@ -12,14 +12,16 @@ import { toast } from 'react-toastify'
|
||||
import { MetadataController, ZapController } from '../controllers'
|
||||
import { useAppSelector, useDidMount } from '../hooks'
|
||||
import '../styles/popup.css'
|
||||
import { PaymentRequest } from '../types'
|
||||
import { PaymentRequest, UserProfile } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
formatNumber,
|
||||
getTagValue,
|
||||
getZapAmount,
|
||||
unformatNumber
|
||||
} from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
|
||||
|
||||
type PresetAmountProps = {
|
||||
label: string
|
||||
@ -460,3 +462,405 @@ export const ZapPopUp = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapSplitProps = {
|
||||
pubkey: string
|
||||
eventId?: string
|
||||
aTag?: string
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const ZapSplit = ({
|
||||
pubkey,
|
||||
eventId,
|
||||
aTag,
|
||||
setTotalZapAmount,
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapSplitProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [authorPercentage, setAuthorPercentage] = useState(95)
|
||||
const [adminPercentage, setAdminPercentage] = useState(5)
|
||||
|
||||
const [author, setAuthor] = useState<UserProfile>()
|
||||
const [admin, setAdmin] = useState<UserProfile>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [invoices, setInvoices] = useState<Map<string, PaymentRequest>>()
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController.findMetadata(pubkey).then((res) => {
|
||||
setAuthor(res)
|
||||
})
|
||||
|
||||
metadataController.findAdminMetadata().then((res) => {
|
||||
setAdmin(res)
|
||||
})
|
||||
})
|
||||
|
||||
const handleAuthorPercentageChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newValue = parseInt(e.target.value)
|
||||
setAuthorPercentage(newValue)
|
||||
setAdminPercentage(100 - newValue)
|
||||
}
|
||||
|
||||
const handleAdminPercentageChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newValue = parseInt(e.target.value)
|
||||
setAdminPercentage(newValue)
|
||||
setAuthorPercentage(100 - newValue) // Update the other slider to maintain 100%
|
||||
}
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const generatePaymentInvoices = async () => {
|
||||
if (!amount) return null
|
||||
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
const adminShare = Math.floor((amount * adminPercentage) / 100)
|
||||
const authorShare = amount - adminShare
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
const invoices = new Map<string, PaymentRequest>()
|
||||
|
||||
if (authorShare > 0 && author?.pubkey && author?.lud16) {
|
||||
setLoadingSpinnerDesc('Generating invoice for author')
|
||||
const invoice = await zapController
|
||||
.getLightningPaymentRequest(
|
||||
author.lud16,
|
||||
authorShare,
|
||||
author.pubkey as string,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (invoice) {
|
||||
invoices.set('author', invoice)
|
||||
}
|
||||
}
|
||||
|
||||
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
|
||||
setLoadingSpinnerDesc('Generating invoice for site owner')
|
||||
const invoice = await zapController
|
||||
.getLightningPaymentRequest(
|
||||
admin.lud16,
|
||||
adminShare,
|
||||
admin.pubkey as string,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (invoice) {
|
||||
invoices.set('admin', invoice)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
return invoices
|
||||
}
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const paymentInvoices = await generatePaymentInvoices()
|
||||
|
||||
if (!paymentInvoices) return
|
||||
|
||||
setInvoices(paymentInvoices)
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const paymentInvoices = await generatePaymentInvoices()
|
||||
|
||||
if (!paymentInvoices) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
const authorInvoice = paymentInvoices.get('author')
|
||||
if (authorInvoice) {
|
||||
setLoadingSpinnerDesc('Sending payment to author')
|
||||
|
||||
const sats = parseInt(getTagValue(authorInvoice, 'amount')![0]) / 1000
|
||||
|
||||
await zapController
|
||||
.sendPayment(authorInvoice.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${sats} sats to author!`)
|
||||
if (setTotalZapAmount) {
|
||||
setTotalZapAmount((prev) => prev + sats)
|
||||
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
}
|
||||
|
||||
const adminInvoice = paymentInvoices.get('admin')
|
||||
if (adminInvoice) {
|
||||
setLoadingSpinnerDesc('Sending payment to site owner')
|
||||
|
||||
const sats = parseInt(getTagValue(adminInvoice, 'amount')![0]) / 1000
|
||||
|
||||
await zapController
|
||||
.sendPayment(adminInvoice.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${sats} sats to site owner!`)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
}
|
||||
|
||||
handleClose()
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setInvoices(paymentInvoices)
|
||||
}
|
||||
}
|
||||
|
||||
const removeInvoice = (key: string) => {
|
||||
setInvoices((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(key)
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
|
||||
const displayQR = () => {
|
||||
if (!invoices) return null
|
||||
|
||||
const authorInvoice = invoices.get('author')
|
||||
if (authorInvoice) {
|
||||
return (
|
||||
<ZapQR
|
||||
key={authorInvoice.pr}
|
||||
paymentRequest={authorInvoice}
|
||||
handleClose={() => removeInvoice('author')}
|
||||
handleQRExpiry={() => removeInvoice('author')}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const adminInvoice = invoices.get('admin')
|
||||
if (adminInvoice) {
|
||||
return (
|
||||
<ZapQR
|
||||
key={adminInvoice.pr}
|
||||
paymentRequest={adminInvoice}
|
||||
handleClose={() => {
|
||||
removeInvoice('admin')
|
||||
handleClose()
|
||||
}}
|
||||
handleQRExpiry={() => removeInvoice('admin')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const authorName = author?.displayName || author?.name || '[name not set up]'
|
||||
const adminName = admin?.displayName || admin?.name || '[name not set up]'
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Tip/Zap</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='pUMCB_ZapsInsideAmount'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Amount (Satoshis)
|
||||
</label>
|
||||
<input
|
||||
className='inputMain'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Message (optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Tip Split</label>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('${
|
||||
author?.image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>
|
||||
{authorName}
|
||||
</p>
|
||||
{author?.nip05 && (
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'>
|
||||
{author.nip05}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ZapSplitUserBoxRange'>
|
||||
<input
|
||||
className='form-range inputRangeMain inputRangeMainZap'
|
||||
type='range'
|
||||
max='100'
|
||||
min='0'
|
||||
value={authorPercentage}
|
||||
onChange={handleAuthorPercentageChange}
|
||||
step='1'
|
||||
required
|
||||
name='ZapSplitName'
|
||||
/>
|
||||
<p className='ZapSplitUserBoxRangeText'>
|
||||
{authorPercentage}%
|
||||
</p>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
This goes to show your appreciation to the mod creator!
|
||||
</p>
|
||||
</div>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('${
|
||||
admin?.image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>
|
||||
{adminName}
|
||||
</p>
|
||||
{admin?.nip05 && (
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ZapSplitUserBoxRange'>
|
||||
<input
|
||||
className='form-range inputRangeMain inputRangeMainZap'
|
||||
type='range'
|
||||
max='100'
|
||||
min='0'
|
||||
value={adminPercentage}
|
||||
onChange={handleAdminPercentageChange}
|
||||
step='1'
|
||||
required
|
||||
name='ZapSplitName'
|
||||
/>
|
||||
<p className='ZapSplitUserBoxRangeText'>
|
||||
{adminPercentage}%
|
||||
</p>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
Help with the development, maintenance, management, and
|
||||
growth of DEG Mods.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{displayQR()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { ZapButtons, ZapPopUp, ZapPresets, ZapQR } from 'components/Zap'
|
||||
import { MetadataController, RelayController, ZapController } from 'controllers'
|
||||
import { ZapSplit } from 'components/Zap'
|
||||
import { RelayController } from 'controllers'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ModDetails, PaymentRequest } from 'types'
|
||||
import { abbreviateNumber, formatNumber, unformatNumber } from 'utils'
|
||||
import { ModDetails } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
|
||||
type ZapProps = {
|
||||
modDetails: ModDetails
|
||||
@ -65,192 +64,15 @@ export const Zap = ({ modDetails }: ZapProps) => {
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={modDetails.author}
|
||||
<ZapSplit
|
||||
pubkey={modDetails.author}
|
||||
eventId={modDetails.id}
|
||||
aTag={modDetails.aTag}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
lastNode={<ZapSite />}
|
||||
notCloseAfterZap
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ZapSite = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [amount, setAmount] = useState(0)
|
||||
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
const handleQRExpiry = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
}, [])
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Getting admin metadata')
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const adminMetadata = await metadataController.findAdminMetadata()
|
||||
|
||||
if (!adminMetadata?.lud16) {
|
||||
setIsLoading(false)
|
||||
toast.error('Lighting address (lud16) is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!adminMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('pubkey is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
setLoadingSpinnerDesc('Creating zap request')
|
||||
return await zapController
|
||||
.getLightningPaymentRequest(
|
||||
adminMetadata.lud16,
|
||||
amount,
|
||||
adminMetadata.pubkey as string,
|
||||
userHexKey
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, userState])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
await zapController
|
||||
.sendPayment(pr.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${amount} sats!`)
|
||||
handleClose()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [amount, handleClose, generatePaymentRequest])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Tip DEG Mods too (Optional)
|
||||
</label>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('/assets/img/Logo%20with%20circle.png')
|
||||
center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>DEG Mods</p>
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'>
|
||||
degmods@degmods.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
Help with the development, maintenance, management, and growth of
|
||||
DEG Mods.
|
||||
</p>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Amount (Satoshis)</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='numeric'
|
||||
placeholder='69 or 420? or 69,420?'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{paymentRequest && (
|
||||
<ZapQR
|
||||
paymentRequest={paymentRequest}
|
||||
handleClose={handleClose}
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user