diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 849dc1ac..09414a52 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -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
  • crosspost this discussion as a NIP-23 event
  • + case 'link': + return
  • crosspost this link as a NIP-01 event
  • + case 'bounty': + return
  • crosspost this bounty as a NIP-99 event
  • + case 'poll': + return
  • crosspost this poll as a NIP-41 event
  • + default: + return null + } + } return ( - {me && router.query.type === 'discussion' && + {me && itemType && crosspost to nostr
      -
    • crosspost this discussion item to nostr
    • + {renderCrosspostDetails(itemType)}
    • requires NIP-07 extension for signing
    • we use your NIP-05 relays if set
    • -
    • otherwise we default to these relays:
    • +
    • we use these relays by default:
      • {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
      • {relay}
      • diff --git a/components/bounty-form.js b/components/bounty-form.js index ce629af1..4da4162e 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -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 } /> - + ) diff --git a/components/discussion-form.js b/components/discussion-form.js index 3aaa4b2c..0860b60e 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -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 ({ ?
        : null} /> - + {!item &&
        0 ? '' : 'invisible'}`}> diff --git a/components/item-info.js b/components/item-info.js index bbfc0588..db84b473 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -165,6 +165,8 @@ export default function ItemInfo ({ nostr note )} + {item && item.mine && !item.noteId && !item.isJob && !item.parentId && + } {me && !item.position && !item.mine && !item.deletedAt && (item.meDontLikeSats > meTotalSats @@ -185,8 +187,6 @@ export default function ItemInfo ({
        } - {item?.mine && !item?.noteId && - } {item.mine && !item.position && !item.deletedAt && !item.bio && <>
        diff --git a/components/link-form.js b/components/link-form.js index e2eed965..3858ee57 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -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 }) { } }} /> - + { + 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} /> - + { + try { + await crossposter(item.id) + } catch (e) { + console.error(e) + toaster.danger('Crosspost failed') + } + } return ( - { - 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 ) diff --git a/components/use-crossposter.js b/components/use-crossposter.js index 552bf50d..80ad3dd6 100644 --- a/components/use-crossposter.js +++ b/components/use-crossposter.js @@ -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(', ')}
        , - () => 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 } diff --git a/lib/nostr.js b/lib/nostr.js index 75d5125b..c016b0e0 100644 --- a/lib/nostr.js +++ b/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 } } } diff --git a/pages/settings/index.js b/pages/settings/index.js index e2667062..056fe38e 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -457,10 +457,10 @@ export default function Settings ({ ssrData }) {
        crosspost to nostr
          -
        • crosspost discussions to nostr
        • +
        • crosspost your items to nostr
        • requires NIP-07 extension for signing
        • we use your NIP-05 relays if set
        • -
        • otherwise we default to these relays:
        • +
        • we use these relays by default:
          • {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => (
          • {relay}