Nostr crossposting all item types (#779)

* crosspost-item

* crosspost old items, update with nEventId

* Updating noteId encoding, cleaning up a little

* Fixing item-info condition, cleaning up

* Linting

* Add createdAt variable back

* Change instances of eventId to noteId

* Adding upsertNoteId mutation

* Cleaning up updateItem, using toasts to communivate success/failure in crosspost-item

* Linting

* Move crosspost to share button, make sure only OP can crosspost

* Lint

* Simplify conditions

* user might have no nostr extension installed

Co-authored-by: ekzyis <27162016+ekzyis@users.noreply.github.com>

* change upsertNoteId to updateNoteID for resolver and mutations, change isOp to mine, remove unused noteId params

* Basic setup for crossposting poll / link items

* post rebase fixes and Bounty and job crossposts

* Job crossposting working

* adding back accidentally removed import

* Lint / rebase

* Outsource as much crossposting logic from discussion-form into use-crossposter as possible

* Fix incorrect property for user relays, fix itemId param in updateNoteId

* Fix toast messages / error cases in use-crossposter

* Update item forms to for updated use-crossposter hook

* CrosspostDropdownItem in share updated to accomodate use-crossposter update

* Encode paramaterized replacable event id's in naddress format with nostr-tools, bounty to follw nip-99 spec

* Increase timeout on relay connection / cleaning up

* No longer crossposting job

* Add blastr, fix crosspost button in item-info for polls/discussions, finish removing job crosspostr code

* Fix toaster error, create reusable crossposterror function to surface toaster

* Cleaning up / comments / linting

* Update copy

* Simplify CrosspostdropdownItem, keep replies from being crossposted

* Moved query for missing item fields when crossposting to use-crossposter hook

* Remove unneeded param in CrosspostDropdownItem, lint

* Small fixes post rebase

* Remove unused import

* fix nostr-tools version, fix package-lock.json

* Update components/item-info.js

Co-authored-by: ekzyis <ek@stacker.news>

* Remove unused param, determine poll item type from pollCost field, add mutiny strfry relay to defaults

* Update toaster implementations, use no-cache for item query, restructure crosspostItem to use await with try catch

* crosspost info modal that lives under adv-post-form now has dynamic crossposting info

* Move determineItemType into handleEventCreation, mover item/event handing outside of do ... while loop

* Lint

* Reconcile skip method with onCancel function in toaster

* Handle failedRelays being undefined

* determine item type from router.query.type if available otherwise use item fields

* Initiliaze failerRelays as undefined but handle error explicitly

* Lint

* Fix crosspost default value for link, poll, bounty forms

---------

Co-authored-by: ekzyis <27162016+ekzyis@users.noreply.github.com>
Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
Austin Kelsay 2024-02-21 19:18:36 -06:00 committed by GitHub
parent c57fcd6518
commit 565e939245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 317 additions and 128 deletions

View File

@ -1,3 +1,4 @@
import { useState, useEffect } from 'react'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
@ -7,8 +8,8 @@ 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' import { useMe } from './me'
import { useRouter } from 'next/router'
import { useFeeButton } from './fee-button' import { useFeeButton } from './fee-button'
import { useRouter } from 'next/router'
const EMPTY_FORWARD = { nym: '', pct: '' } const EMPTY_FORWARD = { nym: '', pct: '' }
@ -19,10 +20,51 @@ export function AdvPostInitial ({ forward, boost }) {
} }
} }
export default function AdvPostForm ({ children }) { export default function AdvPostForm ({ children, item }) {
const me = useMe() const me = useMe()
const router = useRouter()
const { merge } = useFeeButton() const { merge } = useFeeButton()
const router = useRouter()
const [itemType, setItemType] = useState()
useEffect(() => {
const determineItemType = () => {
if (router && router.query.type) {
return router.query.type
} else if (item) {
const typeMap = {
url: 'link',
bounty: 'bounty',
pollCost: 'poll'
}
for (const [key, type] of Object.entries(typeMap)) {
if (item[key]) {
return type
}
}
return 'discussion'
}
}
const type = determineItemType()
setItemType(type)
}, [item, router])
function renderCrosspostDetails (itemType) {
switch (itemType) {
case 'discussion':
return <li>crosspost this discussion as a NIP-23 event</li>
case 'link':
return <li>crosspost this link as a NIP-01 event</li>
case 'bounty':
return <li>crosspost this bounty as a NIP-99 event</li>
case 'poll':
return <li>crosspost this poll as a NIP-41 event</li>
default:
return null
}
}
return ( return (
<AccordianItem <AccordianItem
@ -93,16 +135,16 @@ export default function AdvPostForm ({ children }) {
) )
}} }}
</VariableInput> </VariableInput>
{me && router.query.type === 'discussion' && {me && itemType &&
<Checkbox <Checkbox
label={ label={
<div className='d-flex align-items-center'>crosspost to nostr <div className='d-flex align-items-center'>crosspost to nostr
<Info> <Info>
<ul className='fw-bold'> <ul className='fw-bold'>
<li>crosspost this discussion item to nostr</li> {renderCrosspostDetails(itemType)}
<li>requires NIP-07 extension for signing</li> <li>requires NIP-07 extension for signing</li>
<li>we use your NIP-05 relays if set</li> <li>we use your NIP-05 relays if set</li>
<li>otherwise we default to these relays:</li> <li>we use these relays by default:</li>
<ul> <ul>
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => ( {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
<li key={i}>{relay}</li> <li key={i}>{relay}</li>

View File

@ -4,6 +4,7 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown' import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form' import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import useCrossposter from './use-crossposter'
import { bountySchema } from '../lib/validate' import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
@ -27,6 +28,7 @@ export function BountyForm ({
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast() const toaster = useToast()
const crossposter = useCrossposter()
const schema = bountySchema({ client, me, existingBoost: item?.boost }) const schema = bountySchema({ client, me, existingBoost: item?.boost })
const [upsertBounty] = useMutation( const [upsertBounty] = useMutation(
gql` gql`
@ -60,7 +62,7 @@ export function BountyForm ({
) )
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, bounty, ...values }) => { async ({ boost, bounty, crosspost, ...values }) => {
const { data, error } = await upsertBounty({ const { data, error } = await upsertBounty({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -75,6 +77,12 @@ export function BountyForm ({
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
const bountyId = data?.upsertBounty?.id
if (crosspost && bountyId) {
await crossposter(bountyId)
}
if (item) { if (item) {
await router.push(`/items/${item.id}`) await router.push(`/items/${item.id}`)
} else { } else {
@ -90,6 +98,7 @@ export function BountyForm ({
initial={{ initial={{
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
bounty: item?.bounty || 1000, bounty: item?.bounty || 1000,
...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 })
@ -134,7 +143,7 @@ export function BountyForm ({
: null : null
} }
/> />
<AdvPostForm edit={!!item} /> <AdvPostForm edit={!!item} item={item} />
<ItemButtonBar itemId={item?.id} canDelete={false} /> <ItemButtonBar itemId={item?.id} canDelete={false} />
</Form> </Form>
) )

View File

@ -15,7 +15,6 @@ import { useMe } from './me'
import useCrossposter from './use-crossposter' import useCrossposter from './use-crossposter'
import { useToast } from './toast' import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
import { callWithTimeout } from '../lib/nostr'
export function DiscussionForm ({ export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title', item, sub, editThreshold, titleLabel = 'title',
@ -41,28 +40,8 @@ export function DiscussionForm ({
}` }`
) )
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, crosspost, ...values }) => { async ({ boost, crosspost, ...values }) => {
try {
if (crosspost) {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 5000)
if (!pubkey) throw new Error('failed to get pubkey')
}
} catch (e) {
console.log(e)
throw new Error(`Nostr extension error: ${e.message}`)
}
const { data, error } = await upsertDiscussion({ const { data, error } = await upsertDiscussion({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -77,25 +56,10 @@ export function DiscussionForm ({
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
let noteId = null
const discussionId = data?.upsertDiscussion?.id const discussionId = data?.upsertDiscussion?.id
try { if (crosspost && discussionId) {
if (crosspost && discussionId) { await crossposter(discussionId)
const crosspostResult = await crossposter({ ...values, id: discussionId })
noteId = crosspostResult?.noteId
if (noteId) {
await updateNoteId({
variables: {
id: discussionId,
noteId
}
})
}
}
} catch (e) {
console.error(e)
toaster.danger('Error crossposting to Nostr', e.message)
} }
if (item) { if (item) {
@ -159,7 +123,7 @@ export function DiscussionForm ({
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div> ? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
: null} : null}
/> />
<AdvPostForm edit={!!item} /> <AdvPostForm edit={!!item} item={item} />
<ItemButtonBar itemId={item?.id} /> <ItemButtonBar itemId={item?.id} />
{!item && {!item &&
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}> <div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>

View File

@ -165,6 +165,8 @@ export default function ItemInfo ({
nostr note nostr note
</Dropdown.Item> </Dropdown.Item>
)} )}
{item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
<CrosspostDropdownItem item={item} />}
{me && !item.position && {me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (item.meDontLikeSats > meTotalSats
@ -185,8 +187,6 @@ export default function ItemInfo ({
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />
<PinSubDropdownItem item={item} /> <PinSubDropdownItem item={item} />
</>} </>}
{item?.mine && !item?.noteId &&
<CrosspostDropdownItem item={item} />}
{item.mine && !item.position && !item.deletedAt && !item.bio && {item.mine && !item.position && !item.deletedAt && !item.bio &&
<> <>
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />

View File

@ -13,6 +13,7 @@ import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { useToast } from './toast' import { useToast } from './toast'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import useCrossposter from './use-crossposter'
import { useMe } from './me' import { useMe } from './me'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
@ -26,6 +27,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const shareUrl = router.query.url const shareUrl = router.query.url
const shareTitle = router.query.title const shareTitle = router.query.title
const crossposter = useCrossposter()
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql` const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
query PageTitleAndUnshorted($url: String!) { query PageTitleAndUnshorted($url: String!) {
pageTitleAndUnshorted(url: $url) { pageTitleAndUnshorted(url: $url) {
@ -78,7 +81,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
) )
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, title, ...values }) => { async ({ boost, crosspost, title, ...values }) => {
const { data, error } = await upsertLink({ const { data, error } = await upsertLink({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -92,6 +95,13 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
const linkId = data?.upsertLink?.id
if (crosspost && linkId) {
await crossposter(linkId)
}
if (item) { if (item) {
await router.push(`/items/${item.id}`) await router.push(`/items/${item.id}`)
} else { } else {
@ -125,6 +135,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
title: item?.title || shareTitle || '', title: item?.title || shareTitle || '',
url: item?.url || shareUrl || '', url: item?.url || shareUrl || '',
text: item?.text || '', text: item?.text || '',
crosspost: item ? !!item.noteId : me?.privates?.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 })
}} }}
@ -183,7 +194,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
} }
}} }}
/> />
<AdvPostForm edit={!!item}> <AdvPostForm edit={!!item} item={item}>
<MarkdownInput <MarkdownInput
label='context' label='context'
name='text' name='text'

View File

@ -9,6 +9,7 @@ import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select' import { SubSelectInitial } from './sub-select'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards, toastDeleteScheduled } from '../lib/form' import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import useCrossposter from './use-crossposter'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast' import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
@ -20,6 +21,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const toaster = useToast() const toaster = useToast()
const schema = pollSchema({ client, me, existingBoost: item?.boost }) const schema = pollSchema({ client, me, existingBoost: item?.boost })
const crossposter = useCrossposter()
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
gql` gql`
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String, mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
@ -33,7 +36,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
) )
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, title, options, ...values }) => { async ({ boost, title, options, crosspost, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { data, error } = await upsertPoll({ const { data, error } = await upsertPoll({
variables: { variables: {
@ -49,6 +52,13 @@ export function PollForm ({ item, sub, editThreshold, children }) {
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
const pollId = data?.upsertPoll?.id
if (crosspost && pollId) {
await crossposter(pollId)
}
if (item) { if (item) {
await router.push(`/items/${item.id}`) await router.push(`/items/${item.id}`)
} else { } else {
@ -67,6 +77,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
title: item?.title || '', title: item?.title || '',
text: item?.text || '', text: item?.text || '',
options: initialOptions || ['', ''], options: initialOptions || ['', ''],
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }), pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
...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 })
@ -100,7 +111,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
: null} : null}
maxLength={MAX_POLL_CHOICE_LENGTH} maxLength={MAX_POLL_CHOICE_LENGTH}
/> />
<AdvPostForm edit={!!item}> <AdvPostForm edit={!!item} item={item}>
<DateTimeInput <DateTimeInput
isClearable isClearable
label='poll expiration' label='poll expiration'

View File

@ -2,11 +2,9 @@ import Dropdown from 'react-bootstrap/Dropdown'
import ShareIcon from '../svgs/share-fill.svg' import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy' import copy from 'clipboard-copy'
import useCrossposter from './use-crossposter' import useCrossposter from './use-crossposter'
import { useMutation, gql } from '@apollo/client'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast' import { useToast } from './toast'
import { SSR } from '../lib/constants' import { SSR } from '../lib/constants'
import { callWithTimeout } from '../lib/nostr'
import { commentSubTreeRootId } from '../lib/item' import { commentSubTreeRootId } from '../lib/item'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -108,50 +106,17 @@ export function CrosspostDropdownItem ({ item }) {
const crossposter = useCrossposter() const crossposter = useCrossposter()
const toaster = useToast() const toaster = useToast()
const [updateNoteId] = useMutation( const handleCrosspostClick = async () => {
gql` try {
mutation updateNoteId($id: ID!, $noteId: String!) { await crossposter(item.id)
updateNoteId(id: $id, noteId: $noteId) { } catch (e) {
id console.error(e)
noteId toaster.danger('Crosspost failed')
} }
}` }
)
return ( return (
<Dropdown.Item <Dropdown.Item onClick={handleCrosspostClick}>
onClick={async () => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 5000)
if (!pubkey) {
throw new Error('not available')
}
} catch (e) {
toaster.danger(`Nostr extension error: ${e.message}`)
return
}
try {
if (item?.id) {
const crosspostResult = await crossposter({ ...item })
const noteId = crosspostResult?.noteId
if (noteId) {
await updateNoteId({
variables: {
id: item.id,
noteId
}
})
}
toaster.success('Crosspost successful')
} else {
toaster.warning('Item ID not available')
}
} catch (e) {
console.error(e)
toaster.danger('Crosspost failed')
}
}}
>
crosspost to nostr crosspost to nostr
</Dropdown.Item> </Dropdown.Item>
) )

View File

@ -1,9 +1,10 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useToast } from './toast' import { useToast } from './toast'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { DEFAULT_CROSSPOSTING_RELAYS, crosspost } from '../lib/nostr' import { DEFAULT_CROSSPOSTING_RELAYS, crosspost, callWithTimeout } from '../lib/nostr'
import { useQuery } from '@apollo/client' import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
import { SETTINGS } from '../fragments/users' import { SETTINGS } from '../fragments/users'
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '../fragments/items'
async function discussionToEvent (item) { async function discussionToEvent (item) {
const createdAt = Math.floor(Date.now() / 1000) const createdAt = Math.floor(Date.now() / 1000)
@ -20,14 +21,89 @@ async function discussionToEvent (item) {
} }
} }
async function linkToEvent (item) {
const createdAt = Math.floor(Date.now() / 1000)
return {
created_at: createdAt,
kind: 1,
content: `${item.title} \n ${item.url}`,
tags: []
}
}
async function pollToEvent (item) {
const createdAt = Math.floor(Date.now() / 1000)
const expiresAt = createdAt + 86400
return {
created_at: createdAt,
kind: 1,
content: item.text,
tags: [
['poll', 'single', expiresAt.toString(), item.title, ...item.poll.options.map(op => op?.option.toString())]
]
}
}
async function bountyToEvent (item) {
const createdAt = Math.floor(Date.now() / 1000)
return {
created_at: createdAt,
kind: 30402,
content: item.text,
tags: [
['d', item.id.toString()],
['title', item.title],
['location', `https://stacker.news/items/${item.id}`],
['price', item.bounty.toString(), 'SATS'],
['t', 'bounty'],
['published_at', createdAt.toString()]
]
}
}
export default function useCrossposter () { export default function useCrossposter () {
const toast = useToast() const toaster = useToast()
const { data } = useQuery(SETTINGS) const { data } = useQuery(SETTINGS)
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostrRelays || [])] const userRelays = data?.settings?.privates?.nostrRelays || []
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...userRelays]
const [fetchItem] = useLazyQuery(
gql`
${ITEM_FULL_FIELDS}
${POLL_FIELDS}
query Item($id: ID!) {
item(id: $id) {
...ItemFullFields
...PollFields
}
}`, {
fetchPolicy: 'no-cache'
}
)
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
const relayError = (failedRelays) => { const relayError = (failedRelays) => {
return new Promise(resolve => { return new Promise(resolve => {
const removeToast = toast.danger( const handleSkip = () => {
resolve('skip')
removeToast()
}
const removeToast = toaster.danger(
<> <>
Crossposting failed for {failedRelays.join(', ')} <br /> Crossposting failed for {failedRelays.join(', ')} <br />
<Button <Button
@ -41,41 +117,140 @@ export default function useCrossposter () {
</Button> </Button>
{' | '} {' | '}
<Button <Button
variant='link' onClick={() => resolve('skip')} variant='link' onClick={handleSkip}
>Skip >Skip
</Button> </Button>
</>, </>,
() => resolve('skip') // will skip if user closes the toast {
onCancel: () => handleSkip()
}
) )
}) })
} }
return useCallback(async item => { const crosspostError = (errorMessage) => {
return toaster.danger(`Error crossposting: ${errorMessage}`)
}
async function handleEventCreation (item) {
const determineItemType = (item) => {
const typeMap = {
url: 'link',
bounty: 'bounty',
pollCost: 'poll'
}
for (const [key, type] of Object.entries(typeMap)) {
if (item[key]) {
return type
}
}
// Default
return 'discussion'
}
const itemType = determineItemType(item)
switch (itemType) {
case 'discussion':
return await discussionToEvent(item)
case 'link':
return await linkToEvent(item)
case 'bounty':
return await bountyToEvent(item)
case 'poll':
return await pollToEvent(item)
default:
return crosspostError('Unknown item type')
}
}
const fetchItemData = async (itemId) => {
try {
const { data } = await fetchItem({ variables: { id: itemId } })
return data?.item
} catch (e) {
console.error(e)
return null
}
}
const crosspostItem = async item => {
let failedRelays let failedRelays
let allSuccessful = false let allSuccessful = false
let noteId let noteId
const event = await handleEventCreation(item)
if (!event) return { allSuccessful, noteId }
do { do {
// XXX we only use discussions right now try {
const event = await discussionToEvent(item) const result = await crosspost(event, failedRelays || relays)
const result = await crosspost(event, failedRelays || relays)
noteId = result.noteId if (result.error) {
failedRelays = []
failedRelays = result.failedRelays.map(relayObj => relayObj.relay) throw new Error(result.error)
if (failedRelays.length > 0) {
const userAction = await relayError(failedRelays)
if (userAction === 'skip') {
toast.success('Skipped failed relays.')
break
} }
} else {
allSuccessful = true noteId = result.noteId
failedRelays = result?.failedRelays?.map(relayObj => relayObj.relay) || []
if (failedRelays.length > 0) {
const userAction = await relayError(failedRelays)
if (userAction === 'skip') {
toaster.success('Skipped failed relays.')
// wait 2 seconds then break
await new Promise(resolve => setTimeout(resolve, 2000))
break
}
} else {
allSuccessful = true
}
} catch (error) {
await crosspostError(error.message)
// wait 2 seconds to show error then break
await new Promise(resolve => setTimeout(resolve, 2000))
return { allSuccessful, noteId }
} }
} while (failedRelays.length > 0) } while (failedRelays.length > 0)
return { allSuccessful, noteId } return { allSuccessful, noteId }
}, [relays, toast]) }
const handleCrosspost = useCallback(async (itemId) => {
try {
const pubkey = await callWithTimeout(() => window.nostr.getPublicKey(), 10000)
if (!pubkey) throw new Error('failed to get pubkey')
} catch (e) {
throw new Error(`Nostr extension error: ${e.message}`)
}
let noteId
try {
if (itemId) {
const item = await fetchItemData(itemId)
const crosspostResult = await crosspostItem(item)
noteId = crosspostResult?.noteId
if (noteId) {
await updateNoteId({
variables: {
id: itemId,
noteId
}
})
}
}
} catch (e) {
console.error(e)
await crosspostError(e.message)
}
}, [updateNoteId, relays, toaster])
return handleCrosspost
} }

View File

@ -1,4 +1,5 @@
import { bech32 } from 'bech32' import { bech32 } from 'bech32'
import { nip19 } from 'nostr-tools'
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/ 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]+$/
@ -9,7 +10,9 @@ export const DEFAULT_CROSSPOSTING_RELAYS = [
'wss://relay.damus.io/', 'wss://relay.damus.io/',
'wss://relay.nostr.band/', 'wss://relay.nostr.band/',
'wss://relay.snort.social/', 'wss://relay.snort.social/',
'wss://nostr21.com/' 'wss://nostr21.com/',
'wss://nostr.mutinywallet.com/',
'wss://relay.mutinywallet.com/'
] ]
export function hexToBech32 (hex, prefix = 'npub') { export function hexToBech32 (hex, prefix = 'npub') {
@ -35,7 +38,7 @@ export function nostrZapDetails (zap) {
async function publishNostrEvent (signedEvent, relay) { async function publishNostrEvent (signedEvent, relay) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = 1000 const timeout = 3000
const wsRelay = new window.WebSocket(relay) const wsRelay = new window.WebSocket(relay)
let timer let timer
let isMessageSentSuccessfully = false let isMessageSentSuccessfully = false
@ -82,7 +85,7 @@ async function publishNostrEvent (signedEvent, relay) {
export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) { export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
try { try {
const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 5000) const signedEvent = await callWithTimeout(() => window.nostr.signEvent(event), 10000)
if (!signedEvent) throw new Error('failed to sign event') if (!signedEvent) throw new Error('failed to sign event')
const promises = relays.map(r => publishNostrEvent(signedEvent, r)) const promises = relays.map(r => publishNostrEvent(signedEvent, r))
@ -98,11 +101,20 @@ export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
} }
}) })
const noteId = hexToBech32(signedEvent.id, 'note') let noteId = null
if (signedEvent.kind !== 1) {
noteId = await nip19.naddrEncode({
kind: signedEvent.kind,
pubkey: signedEvent.pubkey,
identifier: signedEvent.tags[0][1]
})
} else {
noteId = hexToBech32(signedEvent.id, 'note')
}
return { successfulRelays, failedRelays, noteId } return { successfulRelays, failedRelays, noteId }
} catch (error) { } catch (error) {
console.error('Crosspost discussion error:', error) console.error('Crosspost error:', error)
return { error } return { error }
} }
} }

View File

@ -457,10 +457,10 @@ export default function Settings ({ ssrData }) {
<div className='d-flex align-items-center'>crosspost to nostr <div className='d-flex align-items-center'>crosspost to nostr
<Info> <Info>
<ul className='fw-bold'> <ul className='fw-bold'>
<li>crosspost discussions to nostr</li> <li>crosspost your items to nostr</li>
<li>requires NIP-07 extension for signing</li> <li>requires NIP-07 extension for signing</li>
<li>we use your NIP-05 relays if set</li> <li>we use your NIP-05 relays if set</li>
<li>otherwise we default to these relays:</li> <li>we use these relays by default:</li>
<ul> <ul>
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => ( {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
<li key={i}>{relay}</li> <li key={i}>{relay}</li>