Nostr crossposting improvements (#629)
* Add nostr event id field to items * crosspost-item * crosspost old items, update with nEventId * Updating noteId encoding, cleaning up a little * Fixing item-info condition, cleaning up * Linting * Spacing nit * 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 * Fix type * 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 * Use nostr.com for linking out with noteId * lint * add noopener to window.open call * Simplify condition, throw GraphQLError --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: ekzyis <27162016+ekzyis@users.noreply.github.com>
This commit is contained in:
parent
bfcca7d34e
commit
5737027c0f
@ -724,6 +724,18 @@ export default {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateNoteId: async (parent, { id, noteId }, { models }) => {
|
||||||
|
if (!id) {
|
||||||
|
throw new GraphQLError('id required', { extensions: { code: 'BAD_INPUT' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await models.item.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: { noteId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id, noteId }
|
||||||
|
},
|
||||||
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => {
|
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||||
@ -1200,7 +1212,7 @@ const getForwardUsers = async (models, forward) => {
|
|||||||
export const SELECT =
|
export const SELECT =
|
||||||
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
|
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
|
||||||
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
|
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
|
||||||
"Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
"Item"."noteId", "Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
|
||||||
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
|
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
|
||||||
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
|
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
|
||||||
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
|
||||||
|
@ -32,6 +32,7 @@ export default gql`
|
|||||||
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
upsertJob(id: ID, sub: String!, title: String!, company: String!, location: String, remote: Boolean,
|
||||||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
|
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
|
||||||
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
||||||
|
updateNoteId(id: ID!, noteId: String!): Item!
|
||||||
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
|
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
|
||||||
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
|
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
|
||||||
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
|
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
|
||||||
@ -83,6 +84,7 @@ export default gql`
|
|||||||
boost: Int!
|
boost: Int!
|
||||||
bounty: Int
|
bounty: Int
|
||||||
bountyPaidTo: [Int]
|
bountyPaidTo: [Int]
|
||||||
|
noteId: String
|
||||||
sats: Int!
|
sats: Int!
|
||||||
commentSats: Int!
|
commentSats: Int!
|
||||||
lastCommentAt: Date
|
lastCommentAt: Date
|
||||||
|
@ -40,6 +40,16 @@ 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 {
|
try {
|
||||||
@ -64,14 +74,27 @@ export function DiscussionForm ({
|
|||||||
throw new Error({ message: error.toString() })
|
throw new Error({ message: error.toString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let noteId = null
|
||||||
|
const discussionId = data?.upsertDiscussion?.id
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (crosspost && data?.upsertDiscussion?.id) {
|
if (crosspost && discussionId) {
|
||||||
await crossposter({ ...values, id: data.upsertDiscussion.id })
|
const crosspostResult = await crossposter({ ...values, id: discussionId })
|
||||||
|
noteId = crosspostResult?.noteId
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
await updateNoteId({
|
||||||
|
variables: {
|
||||||
|
id: discussionId,
|
||||||
|
noteId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
await router.push(`/items/${item.id}`)
|
await router.push(`/items/${item.id}`)
|
||||||
} else {
|
} else {
|
||||||
|
@ -145,6 +145,11 @@ export default function ItemInfo ({
|
|||||||
</Link>}
|
</Link>}
|
||||||
{me && !item.meSats && !item.position &&
|
{me && !item.meSats && !item.position &&
|
||||||
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
||||||
|
{me && item?.noteId && (
|
||||||
|
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}>
|
||||||
|
nostr note
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
{item.mine && !item.position && !item.deletedAt && !item.bio &&
|
{item.mine && !item.position && !item.deletedAt && !item.bio &&
|
||||||
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
|
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
|
||||||
{me && !item.mine &&
|
{me && !item.mine &&
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import Dropdown from 'react-bootstrap/Dropdown'
|
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 { 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'
|
||||||
@ -15,9 +17,22 @@ const getShareUrl = (item, me) => {
|
|||||||
|
|
||||||
export default function Share ({ item }) {
|
export default function Share ({ item }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
|
const crossposter = useCrossposter()
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const url = getShareUrl(item, me)
|
const url = getShareUrl(item, me)
|
||||||
|
|
||||||
|
const mine = item?.user?.id === me?.id
|
||||||
|
|
||||||
|
const [updateNoteId] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation updateNoteId($id: ID!, $noteId: String!) {
|
||||||
|
updateNoteId(id: $id, noteId: $noteId) {
|
||||||
|
id
|
||||||
|
noteId
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
return !SSR && navigator?.share
|
return !SSR && navigator?.share
|
||||||
? (
|
? (
|
||||||
<div className='ms-auto pointer d-flex align-items-center'>
|
<div className='ms-auto pointer d-flex align-items-center'>
|
||||||
@ -58,6 +73,42 @@ export default function Share ({ item }) {
|
|||||||
>
|
>
|
||||||
copy link
|
copy link
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
|
{mine && !item?.noteId && (
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (!(await window.nostr?.getPublicKey())) {
|
||||||
|
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
|
||||||
|
</Dropdown.Item>
|
||||||
|
)}
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>)
|
</Dropdown>)
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ async function discussionToEvent (item) {
|
|||||||
export default function useCrossposter () {
|
export default function useCrossposter () {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { data } = useQuery(SETTINGS)
|
const { data } = useQuery(SETTINGS)
|
||||||
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostRelays || [])]
|
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostrRelays || [])]
|
||||||
|
|
||||||
const relayError = (failedRelays) => {
|
const relayError = (failedRelays) => {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@ -53,12 +53,15 @@ export default function useCrossposter () {
|
|||||||
return useCallback(async item => {
|
return useCallback(async item => {
|
||||||
let failedRelays
|
let failedRelays
|
||||||
let allSuccessful = false
|
let allSuccessful = false
|
||||||
|
let noteId
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// XXX we only use discussions right now
|
// XXX we only use discussions right now
|
||||||
const event = await discussionToEvent(item)
|
const event = await discussionToEvent(item)
|
||||||
const result = await crosspost(event, failedRelays || relays)
|
const result = await crosspost(event, failedRelays || relays)
|
||||||
|
|
||||||
|
noteId = result.noteId
|
||||||
|
|
||||||
failedRelays = result.failedRelays.map(relayObj => relayObj.relay)
|
failedRelays = result.failedRelays.map(relayObj => relayObj.relay)
|
||||||
|
|
||||||
if (failedRelays.length > 0) {
|
if (failedRelays.length > 0) {
|
||||||
@ -73,6 +76,6 @@ export default function useCrossposter () {
|
|||||||
}
|
}
|
||||||
} while (failedRelays.length > 0)
|
} while (failedRelays.length > 0)
|
||||||
|
|
||||||
return { allSuccessful }
|
return { allSuccessful, noteId }
|
||||||
}, [relays, toast])
|
}, [relays, toast])
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ export const ITEM_FIELDS = gql`
|
|||||||
boost
|
boost
|
||||||
bounty
|
bounty
|
||||||
bountyPaidTo
|
bountyPaidTo
|
||||||
|
noteId
|
||||||
path
|
path
|
||||||
upvotes
|
upvotes
|
||||||
meSats
|
meSats
|
||||||
|
@ -98,9 +98,9 @@ export async function crosspost (event, relays = DEFAULT_CROSSPOSTING_RELAYS) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const eventId = hexToBech32(signedEvent.id, 'nevent')
|
const noteId = hexToBech32(signedEvent.id, 'note')
|
||||||
|
|
||||||
return { successfulRelays, failedRelays, eventId }
|
return { successfulRelays, failedRelays, noteId }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Crosspost discussion error:', error)
|
console.error('Crosspost discussion error:', error)
|
||||||
return { error }
|
return { error }
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "Item" ADD COLUMN "noteId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Item.noteId_unique" ON "Item"("noteId");
|
@ -298,6 +298,7 @@ model Item {
|
|||||||
otsHash String?
|
otsHash String?
|
||||||
imgproxyUrls Json?
|
imgproxyUrls Json?
|
||||||
bounty Int?
|
bounty Int?
|
||||||
|
noteId String? @unique(map: "Item.noteId_unique")
|
||||||
rootId Int?
|
rootId Int?
|
||||||
bountyPaidTo Int[]
|
bountyPaidTo Int[]
|
||||||
upvotes Int @default(0)
|
upvotes Int @default(0)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user