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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}`
)
return (
<Dropdown.Item
onClick={async () => {
const handleCrosspostClick = 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')
}
await crossposter(item.id)
} catch (e) {
console.error(e)
toaster.danger('Crosspost failed')
}
}}
>
}
return (
<Dropdown.Item onClick={handleCrosspostClick}>
crosspost to nostr
</Dropdown.Item>
)

View File

@ -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)
try {
const result = await crosspost(event, failedRelays || relays)
noteId = result.noteId
if (result.error) {
failedRelays = []
throw new Error(result.error)
}
failedRelays = result.failedRelays.map(relayObj => relayObj.relay)
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.')
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
}

View File

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

View File

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