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:
parent
c57fcd6518
commit
565e939245
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import AccordianItem from './accordian-item'
|
||||
import { Input, InputUserSuggest, VariableInput, Checkbox } from './form'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
|
@ -7,8 +8,8 @@ import Info from './info'
|
|||
import { numWithUnits } from '../lib/format'
|
||||
import styles from './adv-post-form.module.css'
|
||||
import { useMe } from './me'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useFeeButton } from './fee-button'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
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 router = useRouter()
|
||||
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 (
|
||||
<AccordianItem
|
||||
|
@ -93,16 +135,16 @@ export default function AdvPostForm ({ children }) {
|
|||
)
|
||||
}}
|
||||
</VariableInput>
|
||||
{me && router.query.type === 'discussion' &&
|
||||
{me && itemType &&
|
||||
<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>
|
||||
{renderCrosspostDetails(itemType)}
|
||||
<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>
|
||||
<li>we use these relays by default:</li>
|
||||
<ul>
|
||||
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
|
||||
<li key={i}>{relay}</li>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { gql, useApolloClient, useMutation } from '@apollo/client'
|
|||
import Countdown from './countdown'
|
||||
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import useCrossposter from './use-crossposter'
|
||||
import { bountySchema } from '../lib/validate'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
import { useCallback } from 'react'
|
||||
|
@ -27,6 +28,7 @@ export function BountyForm ({
|
|||
const client = useApolloClient()
|
||||
const me = useMe()
|
||||
const toaster = useToast()
|
||||
const crossposter = useCrossposter()
|
||||
const schema = bountySchema({ client, me, existingBoost: item?.boost })
|
||||
const [upsertBounty] = useMutation(
|
||||
gql`
|
||||
|
@ -60,7 +62,7 @@ export function BountyForm ({
|
|||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ boost, bounty, ...values }) => {
|
||||
async ({ boost, bounty, crosspost, ...values }) => {
|
||||
const { data, error } = await upsertBounty({
|
||||
variables: {
|
||||
sub: item?.subName || sub?.name,
|
||||
|
@ -75,6 +77,12 @@ export function BountyForm ({
|
|||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
const bountyId = data?.upsertBounty?.id
|
||||
|
||||
if (crosspost && bountyId) {
|
||||
await crossposter(bountyId)
|
||||
}
|
||||
|
||||
if (item) {
|
||||
await router.push(`/items/${item.id}`)
|
||||
} else {
|
||||
|
@ -90,6 +98,7 @@ export function BountyForm ({
|
|||
initial={{
|
||||
title: item?.title || '',
|
||||
text: item?.text || '',
|
||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||
bounty: item?.bounty || 1000,
|
||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
|
@ -134,7 +143,7 @@ export function BountyForm ({
|
|||
: null
|
||||
}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} />
|
||||
<AdvPostForm edit={!!item} item={item} />
|
||||
<ItemButtonBar itemId={item?.id} canDelete={false} />
|
||||
</Form>
|
||||
)
|
||||
|
|
|
@ -15,7 +15,6 @@ import { useMe } from './me'
|
|||
import useCrossposter from './use-crossposter'
|
||||
import { useToast } from './toast'
|
||||
import { ItemButtonBar } from './post'
|
||||
import { callWithTimeout } from '../lib/nostr'
|
||||
|
||||
export function DiscussionForm ({
|
||||
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(
|
||||
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({
|
||||
variables: {
|
||||
sub: item?.subName || sub?.name,
|
||||
|
@ -77,25 +56,10 @@ export function DiscussionForm ({
|
|||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
let noteId = null
|
||||
const discussionId = data?.upsertDiscussion?.id
|
||||
|
||||
try {
|
||||
if (crosspost && 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 (crosspost && discussionId) {
|
||||
await crossposter(discussionId)
|
||||
}
|
||||
|
||||
if (item) {
|
||||
|
@ -159,7 +123,7 @@ export function DiscussionForm ({
|
|||
? <div className='text-muted fw-bold'><Countdown date={editThreshold} /></div>
|
||||
: null}
|
||||
/>
|
||||
<AdvPostForm edit={!!item} />
|
||||
<AdvPostForm edit={!!item} item={item} />
|
||||
<ItemButtonBar itemId={item?.id} />
|
||||
{!item &&
|
||||
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>
|
||||
|
|
|
@ -165,6 +165,8 @@ export default function ItemInfo ({
|
|||
nostr note
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
|
||||
<CrosspostDropdownItem item={item} />}
|
||||
{me && !item.position &&
|
||||
!item.mine && !item.deletedAt &&
|
||||
(item.meDontLikeSats > meTotalSats
|
||||
|
@ -185,8 +187,6 @@ export default function ItemInfo ({
|
|||
<hr className='dropdown-divider' />
|
||||
<PinSubDropdownItem item={item} />
|
||||
</>}
|
||||
{item?.mine && !item?.noteId &&
|
||||
<CrosspostDropdownItem item={item} />}
|
||||
{item.mine && !item.position && !item.deletedAt && !item.bio &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
|
|
|
@ -13,6 +13,7 @@ import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
|||
import { useToast } from './toast'
|
||||
import { SubSelectInitial } from './sub-select'
|
||||
import { MAX_TITLE_LENGTH } from '../lib/constants'
|
||||
import useCrossposter from './use-crossposter'
|
||||
import { useMe } from './me'
|
||||
import { ItemButtonBar } from './post'
|
||||
|
||||
|
@ -26,6 +27,8 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
const shareUrl = router.query.url
|
||||
const shareTitle = router.query.title
|
||||
|
||||
const crossposter = useCrossposter()
|
||||
|
||||
const [getPageTitleAndUnshorted, { data }] = useLazyQuery(gql`
|
||||
query PageTitleAndUnshorted($url: String!) {
|
||||
pageTitleAndUnshorted(url: $url) {
|
||||
|
@ -78,7 +81,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async ({ boost, title, ...values }) => {
|
||||
async ({ boost, crosspost, title, ...values }) => {
|
||||
const { data, error } = await upsertLink({
|
||||
variables: {
|
||||
sub: item?.subName || sub?.name,
|
||||
|
@ -92,6 +95,13 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
const linkId = data?.upsertLink?.id
|
||||
|
||||
if (crosspost && linkId) {
|
||||
await crossposter(linkId)
|
||||
}
|
||||
|
||||
if (item) {
|
||||
await router.push(`/items/${item.id}`)
|
||||
} else {
|
||||
|
@ -125,6 +135,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
|
|||
title: item?.title || shareTitle || '',
|
||||
url: item?.url || shareUrl || '',
|
||||
text: item?.text || '',
|
||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||
...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
|
||||
label='context'
|
||||
name='text'
|
||||
|
|
|
@ -9,6 +9,7 @@ import { pollSchema } from '../lib/validate'
|
|||
import { SubSelectInitial } from './sub-select'
|
||||
import { useCallback } from 'react'
|
||||
import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
|
||||
import useCrossposter from './use-crossposter'
|
||||
import { useMe } from './me'
|
||||
import { useToast } from './toast'
|
||||
import { ItemButtonBar } from './post'
|
||||
|
@ -20,6 +21,8 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
const toaster = useToast()
|
||||
const schema = pollSchema({ client, me, existingBoost: item?.boost })
|
||||
|
||||
const crossposter = useCrossposter()
|
||||
|
||||
const [upsertPoll] = useMutation(
|
||||
gql`
|
||||
mutation upsertPoll($sub: String, $id: ID, $title: String!, $text: String,
|
||||
|
@ -33,7 +36,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
)
|
||||
|
||||
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 { data, error } = await upsertPoll({
|
||||
variables: {
|
||||
|
@ -49,6 +52,13 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
if (error) {
|
||||
throw new Error({ message: error.toString() })
|
||||
}
|
||||
|
||||
const pollId = data?.upsertPoll?.id
|
||||
|
||||
if (crosspost && pollId) {
|
||||
await crossposter(pollId)
|
||||
}
|
||||
|
||||
if (item) {
|
||||
await router.push(`/items/${item.id}`)
|
||||
} else {
|
||||
|
@ -67,6 +77,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
title: item?.title || '',
|
||||
text: item?.text || '',
|
||||
options: initialOptions || ['', ''],
|
||||
crosspost: item ? !!item.noteId : me?.privates?.nostrCrossposting,
|
||||
pollExpiresAt: item ? item.pollExpiresAt : datePivot(new Date(), { hours: 25 }),
|
||||
...AdvPostInitial({ forward: normalizeForwards(item?.forwards), boost: item?.boost }),
|
||||
...SubSelectInitial({ sub: item?.subName || sub?.name })
|
||||
|
@ -100,7 +111,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
|
|||
: null}
|
||||
maxLength={MAX_POLL_CHOICE_LENGTH}
|
||||
/>
|
||||
<AdvPostForm edit={!!item}>
|
||||
<AdvPostForm edit={!!item} item={item}>
|
||||
<DateTimeInput
|
||||
isClearable
|
||||
label='poll expiration'
|
||||
|
|
|
@ -2,11 +2,9 @@ import Dropdown from 'react-bootstrap/Dropdown'
|
|||
import ShareIcon from '../svgs/share-fill.svg'
|
||||
import copy from 'clipboard-copy'
|
||||
import useCrossposter from './use-crossposter'
|
||||
import { useMutation, gql } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
import { useToast } from './toast'
|
||||
import { SSR } from '../lib/constants'
|
||||
import { callWithTimeout } from '../lib/nostr'
|
||||
import { commentSubTreeRootId } from '../lib/item'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
|
@ -108,50 +106,17 @@ export function CrosspostDropdownItem ({ item }) {
|
|||
const crossposter = useCrossposter()
|
||||
const toaster = useToast()
|
||||
|
||||
const [updateNoteId] = useMutation(
|
||||
gql`
|
||||
mutation updateNoteId($id: ID!, $noteId: String!) {
|
||||
updateNoteId(id: $id, noteId: $noteId) {
|
||||
id
|
||||
noteId
|
||||
}
|
||||
}`
|
||||
)
|
||||
const handleCrosspostClick = async () => {
|
||||
try {
|
||||
await crossposter(item.id)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toaster.danger('Crosspost failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown.Item
|
||||
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')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item onClick={handleCrosspostClick}>
|
||||
crosspost to nostr
|
||||
</Dropdown.Item>
|
||||
)
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
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 { DEFAULT_CROSSPOSTING_RELAYS, crosspost, callWithTimeout } from '../lib/nostr'
|
||||
import { gql, useMutation, useQuery, useLazyQuery } from '@apollo/client'
|
||||
import { SETTINGS } from '../fragments/users'
|
||||
import { ITEM_FULL_FIELDS, POLL_FIELDS } from '../fragments/items'
|
||||
|
||||
async function discussionToEvent (item) {
|
||||
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 () {
|
||||
const toast = useToast()
|
||||
const toaster = useToast()
|
||||
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) => {
|
||||
return new Promise(resolve => {
|
||||
const removeToast = toast.danger(
|
||||
const handleSkip = () => {
|
||||
resolve('skip')
|
||||
|
||||
removeToast()
|
||||
}
|
||||
|
||||
const removeToast = toaster.danger(
|
||||
<>
|
||||
Crossposting failed for {failedRelays.join(', ')} <br />
|
||||
<Button
|
||||
|
@ -41,41 +117,140 @@ export default function useCrossposter () {
|
|||
</Button>
|
||||
{' | '}
|
||||
<Button
|
||||
variant='link' onClick={() => resolve('skip')}
|
||||
variant='link' onClick={handleSkip}
|
||||
>Skip
|
||||
</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 allSuccessful = false
|
||||
let noteId
|
||||
|
||||
const event = await handleEventCreation(item)
|
||||
if (!event) return { allSuccessful, noteId }
|
||||
|
||||
do {
|
||||
// XXX we only use discussions right now
|
||||
const event = await discussionToEvent(item)
|
||||
const result = await crosspost(event, failedRelays || relays)
|
||||
try {
|
||||
const result = await crosspost(event, failedRelays || relays)
|
||||
|
||||
noteId = result.noteId
|
||||
|
||||
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
|
||||
if (result.error) {
|
||||
failedRelays = []
|
||||
throw new Error(result.error)
|
||||
}
|
||||
} 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
22
lib/nostr.js
22
lib/nostr.js
|
@ -1,4 +1,5 @@
|
|||
import { bech32 } from 'bech32'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export const NOSTR_PUBKEY_HEX = /^[0-9a-fA-F]{64}$/
|
||||
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.nostr.band/',
|
||||
'wss://relay.snort.social/',
|
||||
'wss://nostr21.com/'
|
||||
'wss://nostr21.com/',
|
||||
'wss://nostr.mutinywallet.com/',
|
||||
'wss://relay.mutinywallet.com/'
|
||||
]
|
||||
|
||||
export function hexToBech32 (hex, prefix = 'npub') {
|
||||
|
@ -35,7 +38,7 @@ export function nostrZapDetails (zap) {
|
|||
|
||||
async function publishNostrEvent (signedEvent, relay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 1000
|
||||
const timeout = 3000
|
||||
const wsRelay = new window.WebSocket(relay)
|
||||
let timer
|
||||
let isMessageSentSuccessfully = false
|
||||
|
@ -82,7 +85,7 @@ async function publishNostrEvent (signedEvent, relay) {
|
|||
|
||||
export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
|
||||
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')
|
||||
|
||||
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 }
|
||||
} catch (error) {
|
||||
console.error('Crosspost discussion error:', error)
|
||||
console.error('Crosspost error:', error)
|
||||
return { error }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -457,10 +457,10 @@ export default function Settings ({ ssrData }) {
|
|||
<div className='d-flex align-items-center'>crosspost to nostr
|
||||
<Info>
|
||||
<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>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>
|
||||
{DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
|
||||
<li key={i}>{relay}</li>
|
||||
|
|
Loading…
Reference in New Issue