Crosspost discussion items to nostr (#522)

* Crossposting discussion function, crossposting setting migration

* Passing in id, adding relays to test

* Adding checkbox setting for crossposting enabled

* Adding paramaterized event fields to crosspostDiscussion, successfully crossposting discussions

* Cleaning up for rebase

* Removing nostrRelays

* Retry crosspost toast

* Adding nostrCrossposting to settings, fixing migration

* Full flow is working with error surfacing, retries, and skips for a retry

* Updates to error handling/retries for crossposting, fixing settings for crossposting

* Allowing recursive retries for crossposting to specific relays

* Fixing / syncing crossposting settings, cleaning up and seperating out nostr functions

* Cleaning up

* Running linter

* make nostr crossposter a hook

---------

Co-authored-by: Austin <austin@pop-os.localdomain>
Co-authored-by: plebdev <plebdev@plebdevs-MacBook-Pro.local>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
Austin Kelsay 2023-10-04 13:47:09 -05:00 committed by GitHub
parent f6141a6965
commit b3aee502a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 274 additions and 36 deletions

View File

@ -24,7 +24,7 @@ export default gql`
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, imgproxyOnly: Boolean!, hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, imgproxyOnly: Boolean!,
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!, wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrCrossposting: Boolean, nostrRelays: [String!], hideBookmarks: Boolean!,
noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!, diagnostics: Boolean!): User noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!, diagnostics: Boolean!): User
setPhoto(photoId: ID!): Int! setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User! upsertBio(bio: String!): User!
@ -73,6 +73,7 @@ export default gql`
since: Int since: Int
upvotePopover: Boolean! upvotePopover: Boolean!
tipPopover: Boolean! tipPopover: Boolean!
nostrCrossposting: Boolean!
noteItemSats: Boolean! noteItemSats: Boolean!
noteEarning: Boolean! noteEarning: Boolean!
noteAllDescendants: Boolean! noteAllDescendants: Boolean!

View File

@ -1,10 +1,12 @@
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput } from './form' import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '../lib/constants' import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '../lib/constants'
import { DEFAULT_CROSSPOSTING_RELAYS } from '../lib/nostr'
import Info from './info' import Info from './info'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import styles from './adv-post-form.module.css' import styles from './adv-post-form.module.css'
import { useMe } from './me'
const EMPTY_FORWARD = { nym: '', pct: '' } const EMPTY_FORWARD = { nym: '', pct: '' }
@ -16,6 +18,8 @@ export function AdvPostInitial ({ forward, boost }) {
} }
export default function AdvPostForm () { export default function AdvPostForm () {
const me = useMe()
return ( return (
<AccordianItem <AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
@ -77,6 +81,27 @@ export default function AdvPostForm () {
) )
}} }}
</VariableInput> </VariableInput>
{me &&
<Checkbox
label={
<div className='d-flex align-items-center'>crosspost to nostr
<Info>
<ul className='fw-bold'>
<li>crosspost this discussion item to nostr</li>
<li>requires NIP-07 extension for signing</li>
<li>we use your NIP-05 relays if set</li>
<li>otherwise we default to these relays:</li>
<ul>
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
<li key={i}>{relay}</li>
))}
</ul>
</ul>
</Info>
</div>
}
name='crosspost'
/>}
</> </>
} }
/> />

View File

@ -16,6 +16,7 @@ import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form' import { normalizeForwards } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me' import { useMe } from './me'
import useCrossposter from './use-crossposter'
export function DiscussionForm ({ export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title', item, sub, editThreshold, titleLabel = 'title',
@ -28,6 +29,7 @@ export function DiscussionForm ({
const schema = discussionSchema({ client, me, existingBoost: item?.boost }) const schema = discussionSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used
const shareTitle = router.query.title const shareTitle = router.query.title
const crossposter = useCrossposter()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
@ -39,8 +41,16 @@ export function DiscussionForm ({
) )
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, ...values }) => { async ({ boost, crosspost, ...values }) => {
const { error } = await upsertDiscussion({ try {
if (crosspost && !(await window.nostr.getPublicKey())) {
throw new Error('not available')
}
} catch (e) {
throw new Error(`Nostr extension error: ${e.message}`)
}
const { data, error } = await upsertDiscussion({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
id: item?.id, id: item?.id,
@ -49,17 +59,26 @@ export function DiscussionForm ({
forward: normalizeForwards(values.forward) forward: normalizeForwards(values.forward)
} }
}) })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
try {
if (crosspost && data?.upsertDiscussion?.id) {
await crossposter({ ...values, id: data.upsertDiscussion.id })
}
} catch (e) {
console.error(e)
}
if (item) { if (item) {
await router.push(`/items/${item.id}`) await router.push(`/items/${item.id}`)
} else { } else {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
}, [upsertDiscussion, router] }, [upsertDiscussion, router, item, sub, crossposter]
) )
const [getRelated, { data: relatedData }] = useLazyQuery(gql` const [getRelated, { data: relatedData }] = useLazyQuery(gql`
@ -81,6 +100,7 @@ export function DiscussionForm ({
initial={{ initial={{
title: item?.title || shareTitle || '', title: item?.title || shareTitle || '',
text: item?.text || '', text: item?.text || '',
crosspost: me?.nostrCrossposting,
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }), ...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
...SubSelectInitial({ sub: item?.subName || sub?.name }) ...SubSelectInitial({ sub: item?.subName || sub?.name })
}} }}

View File

@ -89,7 +89,7 @@ function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...pr
) )
} }
export function ZoomableImage ({ src, srcSet, ...props }) { export default function ZoomableImage ({ src, srcSet, ...props }) {
const showModal = useShowModal() const showModal = useShowModal()
// if `srcSet` is falsy, it means the image was not processed by worker yet // if `srcSet` is falsy, it means the image was not processed by worker yet

View File

@ -11,7 +11,7 @@ import LinkIcon from '../svgs/link.svg'
import Thumb from '../svgs/thumb-up-fill.svg' import Thumb from '../svgs/thumb-up-fill.svg'
import { toString } from 'mdast-util-to-string' import { toString } from 'mdast-util-to-string'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import { ZoomableImage, decodeOriginalUrl } from './image' import ZoomableImage, { decodeOriginalUrl } from './image'
import { IMGPROXY_URL_REGEXP } from '../lib/url' import { IMGPROXY_URL_REGEXP } from '../lib/url'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'

View File

@ -19,6 +19,11 @@ export const ToastProvider = ({ children }) => {
} }
setToasts(toasts => [...toasts, toastConfig]) setToasts(toasts => [...toasts, toastConfig])
}, []) }, [])
const removeToast = useCallback(id => {
setToasts(toasts => toasts.filter(toast => toast.id !== id))
}, [])
const toaster = useMemo(() => ({ const toaster = useMemo(() => ({
success: body => { success: body => {
dispatchToast({ dispatchToast({
@ -28,17 +33,20 @@ export const ToastProvider = ({ children }) => {
delay: 5000 delay: 5000
}) })
}, },
danger: body => { danger: (body, onCloseCallback) => {
const id = toastId.current
dispatchToast({ dispatchToast({
id,
body, body,
variant: 'danger', variant: 'danger',
autohide: false autohide: false,
onCloseCallback
}) })
return {
removeToast: () => removeToast(id)
}
} }
}), [dispatchToast]) }), [dispatchToast])
const removeToast = useCallback(id => {
setToasts(toasts => toasts.filter(toast => toast.id !== id))
}, [])
// Clear all toasts on page navigation // Clear all toasts on page navigation
useEffect(() => { useEffect(() => {
@ -65,7 +73,10 @@ export const ToastProvider = ({ children }) => {
variant={null} variant={null}
className='p-0 ps-2' className='p-0 ps-2'
aria-label='close' aria-label='close'
onClick={() => removeToast(toast.id)} onClick={() => {
if (toast.onCloseCallback) toast.onCloseCallback()
removeToast(toast.id)
}}
><div className={styles.toastClose}>X</div> ><div className={styles.toastClose}>X</div>
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,80 @@
import { useCallback } from 'react'
import { useToast } from './toast'
import { Button } from 'react-bootstrap'
import { DEFAULT_CROSSPOSTING_RELAYS, crosspost } from '../lib/nostr'
import { useQuery } from '@apollo/client'
import { SETTINGS } from '../fragments/users'
async function discussionToEvent (item) {
const pubkey = await window.nostr.getPublicKey()
const createdAt = Math.floor(Date.now() / 1000)
return {
created_at: createdAt,
kind: 30023,
content: item.text,
tags: [
['d', `https://stacker.news/items/${item.id}`],
['a', `30023:${pubkey}:https://stacker.news/items/${item.id}`, 'wss://relay.nostr.band'],
['title', item.title],
['published_at', createdAt.toString()]
]
}
}
export default function useCrossposter () {
const toast = useToast()
const { data } = useQuery(SETTINGS)
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostRelays || [])]
const relayError = (failedRelays) => {
return new Promise(resolve => {
const { removeToast } = toast.danger(
<>
Crossposting failed for {failedRelays.join(', ')} <br />
<Button
variant='link' onClick={() => {
resolve('retry')
setTimeout(() => {
removeToast()
}, 1000)
}}
>Retry
</Button>
{' | '}
<Button
variant='link' onClick={() => resolve('skip')}
>Skip
</Button>
</>,
() => resolve('skip') // will skip if user closes the toast
)
})
}
return useCallback(async item => {
let failedRelays
let allSuccessful = false
do {
// XXX we only use discussions right now
const event = await discussionToEvent(item)
const result = await crosspost(event, failedRelays || relays)
failedRelays = result.failedRelays.map(relayObj => relayObj.relay)
if (failedRelays.length > 0) {
const userAction = await relayError(failedRelays)
if (userAction === 'skip') {
toast.success('Skipped failed relays.')
break
}
} else {
allSuccessful = true
}
} while (failedRelays.length > 0)
return { allSuccessful }
}, [relays, toast])
}

View File

@ -18,6 +18,7 @@ export const ME = gql`
bioId bioId
upvotePopover upvotePopover
tipPopover tipPopover
nostrCrossposting
noteItemSats noteItemSats
noteEarning noteEarning
noteAllDescendants noteAllDescendants
@ -65,6 +66,7 @@ export const SETTINGS_FIELDS = gql`
hideWalletBalance hideWalletBalance
diagnostics diagnostics
nostrPubkey nostrPubkey
nostrCrossposting
nostrRelays nostrRelays
wildWestMode wildWestMode
greeterMode greeterMode
@ -92,14 +94,14 @@ mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency:
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!,
$hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $imgproxyOnly: Boolean!, $hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $imgproxyOnly: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!, $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrCrossposting: Boolean!, $nostrRelays: [String!], $hideBookmarks: Boolean!,
$noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!, $diagnostics: Boolean!) { $noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!, $diagnostics: Boolean!) {
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency, setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc,
hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, imgproxyOnly: $imgproxyOnly, hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, imgproxyOnly: $imgproxyOnly,
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks, wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrCrossposting: $nostrCrossposting, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks,
noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor, diagnostics: $diagnostics) { noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor, diagnostics: $diagnostics) {
...SettingsFields ...SettingsFields
} }

View File

@ -4,6 +4,13 @@ export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/ export const NOSTR_PUBKEY_BECH32 = /^npub1[02-9ac-hj-np-z]+$/
export const NOSTR_MAX_RELAY_NUM = 20 export const NOSTR_MAX_RELAY_NUM = 20
export const NOSTR_ZAPPLE_PAY_NPUB = 'npub1wxl6njlcgygduct7jkgzrvyvd9fylj4pqvll6p32h59wyetm5fxqjchcan' export const NOSTR_ZAPPLE_PAY_NPUB = 'npub1wxl6njlcgygduct7jkgzrvyvd9fylj4pqvll6p32h59wyetm5fxqjchcan'
export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://nostrue.com/',
'wss://relay.damus.io/',
'wss://relay.nostr.band/',
'wss://relay.snort.social/',
'wss://nostr21.com/'
]
export function hexToBech32 (hex, prefix = 'npub') { export function hexToBech32 (hex, prefix = 'npub') {
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex'))) return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
@ -25,3 +32,77 @@ export function nostrZapDetails (zap) {
return { npub, content, note } return { npub, content, note }
} }
async function publishNostrEvent (signedEvent, relay) {
return new Promise((resolve, reject) => {
const timeout = 1000
const wsRelay = new window.WebSocket(relay)
let timer
let isMessageSentSuccessfully = false
function timedout () {
clearTimeout(timer)
wsRelay.close()
reject(new Error(`relay timeout for ${relay}`))
}
timer = setTimeout(timedout, timeout)
wsRelay.onopen = function () {
clearTimeout(timer)
timer = setTimeout(timedout, timeout)
wsRelay.send(JSON.stringify(['EVENT', signedEvent]))
}
wsRelay.onmessage = function (msg) {
const m = JSON.parse(msg.data)
if (m[0] === 'OK') {
isMessageSentSuccessfully = true
clearTimeout(timer)
wsRelay.close()
console.log('Successfully sent event to', relay)
resolve()
}
}
wsRelay.onerror = function (error) {
clearTimeout(timer)
console.log(error)
reject(new Error(`relay error: Failed to send to ${relay}`))
}
wsRelay.onclose = function () {
clearTimeout(timer)
if (!isMessageSentSuccessfully) {
reject(new Error(`relay error: Failed to send to ${relay}`))
}
}
})
}
export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
try {
const signedEvent = await window.nostr.signEvent(event)
if (!signedEvent) throw new Error('failed to sign event')
const promises = relays.map(r => publishNostrEvent(signedEvent, r))
const results = await Promise.allSettled(promises)
const successfulRelays = []
const failedRelays = []
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successfulRelays.push(relays[index])
} else {
failedRelays.push({ relay: relays[index], error: result.reason })
}
})
const eventId = hexToBech32(signedEvent.id, 'nevent')
return { successfulRelays, failedRelays, eventId }
} catch (error) {
console.error('Crosspost discussion error:', error)
return { error }
}
}

View File

@ -15,7 +15,7 @@ import Info from '../components/info'
import Link from 'next/link' import Link from 'next/link'
import AccordianItem from '../components/accordian-item' import AccordianItem from '../components/accordian-item'
import { bech32 } from 'bech32' import { bech32 } from 'bech32'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32 } from '../lib/nostr' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '../lib/nostr'
import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate' import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '../lib/validate'
import { SUPPORTED_CURRENCIES } from '../lib/currency' import { SUPPORTED_CURRENCIES } from '../lib/currency'
import PageLoading from '../components/page-loading' import PageLoading from '../components/page-loading'
@ -79,6 +79,7 @@ export default function Settings ({ ssrData }) {
wildWestMode: settings?.wildWestMode, wildWestMode: settings?.wildWestMode,
greeterMode: settings?.greeterMode, greeterMode: settings?.greeterMode,
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '', nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
nostrCrossposting: settings?.nostrCrossposting,
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''], nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''],
hideBookmarks: settings?.hideBookmarks, hideBookmarks: settings?.hideBookmarks,
hideWalletBalance: settings?.hideWalletBalance, hideWalletBalance: settings?.hideWalletBalance,
@ -319,26 +320,40 @@ export default function Settings ({ ssrData }) {
} }
name='greeterMode' name='greeterMode'
/> />
<AccordianItem <h4>nostr</h4>
headerColor='var(--bs-body-color)' <Checkbox
show={settings?.nostrPubkey} label={
header={<h4 className='text-start'>nostr <small><a href='https://github.com/nostr-protocol/nips/blob/master/05.md' target='_blank' rel='noreferrer'>NIP-05</a></small></h4>} <div className='d-flex align-items-center'>crosspost to nostr
body={ <Info>
<> <ul className='fw-bold'>
<Input <li>crosspost discussions to nostr</li>
label={<>pubkey <small className='text-muted ms-2'>optional</small></>} <li>requires NIP-07 extension for signing</li>
name='nostrPubkey' <li>we use your NIP-05 relays if set</li>
clear <li>otherwise we default to these relays:</li>
/> <ul>
<VariableInput {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
label={<>relays <small className='text-muted ms-2'>optional</small></>} <li key={i}>{relay}</li>
name='nostrRelays' ))}
clear </ul>
min={0} </ul>
max={NOSTR_MAX_RELAY_NUM} </Info>
/> </div>
</> }
} name='nostrCrossposting'
/>
<Input
label={<>pubkey <small className='text-muted ms-2'>optional</small></>}
name='nostrPubkey'
clear
hint={<small className='text-muted'>used for NIP-05</small>}
/>
<VariableInput
label={<>relays <small className='text-muted ms-2'>optional</small></>}
name='nostrRelays'
clear
min={0}
max={NOSTR_MAX_RELAY_NUM}
hint={<small className='text-muted'>used for NIP-05 and crossposting</small>}
/> />
<div className='d-flex'> <div className='d-flex'>
<SubmitButton variant='info' className='ms-auto mt-1 px-4'>save</SubmitButton> <SubmitButton variant='info' className='ms-auto mt-1 px-4'>save</SubmitButton>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "nostrCrossposting" BOOLEAN NOT NULL DEFAULT false;

View File

@ -55,6 +55,7 @@ model User {
referrerId Int? referrerId Int?
nostrPubkey String? nostrPubkey String?
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique") nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
nostrCrossposting Boolean @default(false)
slashtagId String? @unique(map: "users.slashtagId_unique") slashtagId String? @unique(map: "users.slashtagId_unique")
noteCowboyHat Boolean @default(true) noteCowboyHat Boolean @default(true)
streak Int? streak Int?