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:
parent
f6141a6965
commit
b3aee502a0
|
@ -24,7 +24,7 @@ export default gql`
|
|||
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
|
||||
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: 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
|
||||
setPhoto(photoId: ID!): Int!
|
||||
upsertBio(bio: String!): User!
|
||||
|
@ -73,6 +73,7 @@ export default gql`
|
|||
since: Int
|
||||
upvotePopover: Boolean!
|
||||
tipPopover: Boolean!
|
||||
nostrCrossposting: Boolean!
|
||||
noteItemSats: Boolean!
|
||||
noteEarning: Boolean!
|
||||
noteAllDescendants: Boolean!
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
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 { BOOST_MIN, BOOST_MULT, MAX_FORWARDS } from '../lib/constants'
|
||||
import { DEFAULT_CROSSPOSTING_RELAYS } from '../lib/nostr'
|
||||
import Info from './info'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import styles from './adv-post-form.module.css'
|
||||
import { useMe } from './me'
|
||||
|
||||
const EMPTY_FORWARD = { nym: '', pct: '' }
|
||||
|
||||
|
@ -16,6 +18,8 @@ export function AdvPostInitial ({ forward, boost }) {
|
|||
}
|
||||
|
||||
export default function AdvPostForm () {
|
||||
const me = useMe()
|
||||
|
||||
return (
|
||||
<AccordianItem
|
||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
|
||||
|
@ -77,6 +81,27 @@ export default function AdvPostForm () {
|
|||
)
|
||||
}}
|
||||
</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'
|
||||
/>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { useCallback } from 'react'
|
|||
import { normalizeForwards } from '../lib/form'
|
||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||
import { useMe } from './me'
|
||||
import useCrossposter from './use-crossposter'
|
||||
|
||||
export function DiscussionForm ({
|
||||
item, sub, editThreshold, titleLabel = 'title',
|
||||
|
@ -28,6 +29,7 @@ export function DiscussionForm ({
|
|||
const schema = discussionSchema({ client, me, existingBoost: item?.boost })
|
||||
// if Web Share Target API was used
|
||||
const shareTitle = router.query.title
|
||||
const crossposter = useCrossposter()
|
||||
|
||||
const [upsertDiscussion] = useMutation(
|
||||
gql`
|
||||
|
@ -39,8 +41,16 @@ export function DiscussionForm ({
|
|||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ boost, ...values }) => {
|
||||
const { error } = await upsertDiscussion({
|
||||
async ({ boost, crosspost, ...values }) => {
|
||||
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: {
|
||||
sub: item?.subName || sub?.name,
|
||||
id: item?.id,
|
||||
|
@ -49,17 +59,26 @@ export function DiscussionForm ({
|
|||
forward: normalizeForwards(values.forward)
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
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) {
|
||||
await router.push(`/items/${item.id}`)
|
||||
} else {
|
||||
const prefix = sub?.name ? `/~${sub.name}` : ''
|
||||
await router.push(prefix + '/recent')
|
||||
}
|
||||
}, [upsertDiscussion, router]
|
||||
}, [upsertDiscussion, router, item, sub, crossposter]
|
||||
)
|
||||
|
||||
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
|
||||
|
@ -81,6 +100,7 @@ export function DiscussionForm ({
|
|||
initial={{
|
||||
title: item?.title || shareTitle || '',
|
||||
text: item?.text || '',
|
||||
crosspost: me?.nostrCrossposting,
|
||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
}}
|
||||
|
|
|
@ -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()
|
||||
|
||||
// if `srcSet` is falsy, it means the image was not processed by worker yet
|
||||
|
|
|
@ -11,7 +11,7 @@ import LinkIcon from '../svgs/link.svg'
|
|||
import Thumb from '../svgs/thumb-up-fill.svg'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import copy from 'clipboard-copy'
|
||||
import { ZoomableImage, decodeOriginalUrl } from './image'
|
||||
import ZoomableImage, { decodeOriginalUrl } from './image'
|
||||
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
|
||||
|
|
|
@ -19,6 +19,11 @@ export const ToastProvider = ({ children }) => {
|
|||
}
|
||||
setToasts(toasts => [...toasts, toastConfig])
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback(id => {
|
||||
setToasts(toasts => toasts.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
const toaster = useMemo(() => ({
|
||||
success: body => {
|
||||
dispatchToast({
|
||||
|
@ -28,17 +33,20 @@ export const ToastProvider = ({ children }) => {
|
|||
delay: 5000
|
||||
})
|
||||
},
|
||||
danger: body => {
|
||||
danger: (body, onCloseCallback) => {
|
||||
const id = toastId.current
|
||||
dispatchToast({
|
||||
id,
|
||||
body,
|
||||
variant: 'danger',
|
||||
autohide: false
|
||||
autohide: false,
|
||||
onCloseCallback
|
||||
})
|
||||
return {
|
||||
removeToast: () => removeToast(id)
|
||||
}
|
||||
}
|
||||
}), [dispatchToast])
|
||||
const removeToast = useCallback(id => {
|
||||
setToasts(toasts => toasts.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
// Clear all toasts on page navigation
|
||||
useEffect(() => {
|
||||
|
@ -65,7 +73,10 @@ export const ToastProvider = ({ children }) => {
|
|||
variant={null}
|
||||
className='p-0 ps-2'
|
||||
aria-label='close'
|
||||
onClick={() => removeToast(toast.id)}
|
||||
onClick={() => {
|
||||
if (toast.onCloseCallback) toast.onCloseCallback()
|
||||
removeToast(toast.id)
|
||||
}}
|
||||
><div className={styles.toastClose}>X</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -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])
|
||||
}
|
|
@ -18,6 +18,7 @@ export const ME = gql`
|
|||
bioId
|
||||
upvotePopover
|
||||
tipPopover
|
||||
nostrCrossposting
|
||||
noteItemSats
|
||||
noteEarning
|
||||
noteAllDescendants
|
||||
|
@ -65,6 +66,7 @@ export const SETTINGS_FIELDS = gql`
|
|||
hideWalletBalance
|
||||
diagnostics
|
||||
nostrPubkey
|
||||
nostrCrossposting
|
||||
nostrRelays
|
||||
wildWestMode
|
||||
greeterMode
|
||||
|
@ -92,14 +94,14 @@ mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency:
|
|||
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
|
||||
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: 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!) {
|
||||
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
|
||||
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
|
||||
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
|
||||
noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc,
|
||||
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) {
|
||||
...SettingsFields
|
||||
}
|
||||
|
|
81
lib/nostr.js
81
lib/nostr.js
|
@ -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_MAX_RELAY_NUM = 20
|
||||
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') {
|
||||
return bech32.encode(prefix, bech32.toWords(Buffer.from(hex, 'hex')))
|
||||
|
@ -25,3 +32,77 @@ export function nostrZapDetails (zap) {
|
|||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ import Info from '../components/info'
|
|||
import Link from 'next/link'
|
||||
import AccordianItem from '../components/accordian-item'
|
||||
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 { SUPPORTED_CURRENCIES } from '../lib/currency'
|
||||
import PageLoading from '../components/page-loading'
|
||||
|
@ -79,6 +79,7 @@ export default function Settings ({ ssrData }) {
|
|||
wildWestMode: settings?.wildWestMode,
|
||||
greeterMode: settings?.greeterMode,
|
||||
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
|
||||
nostrCrossposting: settings?.nostrCrossposting,
|
||||
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''],
|
||||
hideBookmarks: settings?.hideBookmarks,
|
||||
hideWalletBalance: settings?.hideWalletBalance,
|
||||
|
@ -319,26 +320,40 @@ export default function Settings ({ ssrData }) {
|
|||
}
|
||||
name='greeterMode'
|
||||
/>
|
||||
<AccordianItem
|
||||
headerColor='var(--bs-body-color)'
|
||||
show={settings?.nostrPubkey}
|
||||
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>}
|
||||
body={
|
||||
<>
|
||||
<Input
|
||||
label={<>pubkey <small className='text-muted ms-2'>optional</small></>}
|
||||
name='nostrPubkey'
|
||||
clear
|
||||
/>
|
||||
<VariableInput
|
||||
label={<>relays <small className='text-muted ms-2'>optional</small></>}
|
||||
name='nostrRelays'
|
||||
clear
|
||||
min={0}
|
||||
max={NOSTR_MAX_RELAY_NUM}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<h4>nostr</h4>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>crosspost to nostr
|
||||
<Info>
|
||||
<ul className='fw-bold'>
|
||||
<li>crosspost discussions 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='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'>
|
||||
<SubmitButton variant='info' className='ms-auto mt-1 px-4'>save</SubmitButton>
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "nostrCrossposting" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -55,6 +55,7 @@ model User {
|
|||
referrerId Int?
|
||||
nostrPubkey String?
|
||||
nostrAuthPubkey String? @unique(map: "users.nostrAuthPubkey_unique")
|
||||
nostrCrossposting Boolean @default(false)
|
||||
slashtagId String? @unique(map: "users.slashtagId_unique")
|
||||
noteCowboyHat Boolean @default(true)
|
||||
streak Int?
|
||||
|
|
Loading…
Reference in New Issue