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!,
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!

View File

@ -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'
/>}
</>
}
/>

View File

@ -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 })
}}

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()
// 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 { 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'

View File

@ -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>

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
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
}

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_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 }
}
}

View File

@ -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,16 +320,32 @@ 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={
<>
<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></>}
@ -336,9 +353,7 @@ export default function Settings ({ ssrData }) {
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>

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?
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?