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 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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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'}`}>
|
||||||
|
|
|
@ -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' />
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
22
lib/nostr.js
22
lib/nostr.js
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue