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:
Austin Kelsay 2023-12-19 11:48:48 -06:00 committed by GitHub
parent bfcca7d34e
commit 5737027c0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 109 additions and 7 deletions

View File

@ -724,6 +724,18 @@ export default {
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 }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
@ -1200,7 +1212,7 @@ const getForwardUsers = async (models, forward) => {
export const SELECT =
`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"."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"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",

View File

@ -32,6 +32,7 @@ export default gql`
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!
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!
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
@ -83,6 +84,7 @@ export default gql`
boost: Int!
bounty: Int
bountyPaidTo: [Int]
noteId: String
sats: Int!
commentSats: Int!
lastCommentAt: Date

View File

@ -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(
async ({ boost, crosspost, ...values }) => {
try {
@ -64,14 +74,27 @@ export function DiscussionForm ({
throw new Error({ message: error.toString() })
}
let noteId = null
const discussionId = data?.upsertDiscussion?.id
try {
if (crosspost && data?.upsertDiscussion?.id) {
await crossposter({ ...values, id: data.upsertDiscussion.id })
if (crosspost && discussionId) {
const crosspostResult = await crossposter({ ...values, id: discussionId })
noteId = crosspostResult?.noteId
}
} catch (e) {
console.error(e)
}
if (noteId) {
await updateNoteId({
variables: {
id: discussionId,
noteId
}
})
}
if (item) {
await router.push(`/items/${item.id}`)
} else {

View File

@ -145,6 +145,11 @@ export default function ItemInfo ({
</Link>}
{me && !item.meSats && !item.position &&
!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 &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
{me && !item.mine &&

View File

@ -1,6 +1,8 @@
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'
@ -15,9 +17,22 @@ const getShareUrl = (item, me) => {
export default function Share ({ item }) {
const me = useMe()
const crossposter = useCrossposter()
const toaster = useToast()
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
? (
<div className='ms-auto pointer d-flex align-items-center'>
@ -58,6 +73,42 @@ export default function Share ({ item }) {
>
copy link
</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>)
}

View File

@ -23,7 +23,7 @@ async function discussionToEvent (item) {
export default function useCrossposter () {
const toast = useToast()
const { data } = useQuery(SETTINGS)
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostRelays || [])]
const relays = [...DEFAULT_CROSSPOSTING_RELAYS, ...(data?.settings?.nostrRelays || [])]
const relayError = (failedRelays) => {
return new Promise(resolve => {
@ -53,12 +53,15 @@ export default function useCrossposter () {
return useCallback(async item => {
let failedRelays
let allSuccessful = false
let noteId
do {
// XXX we only use discussions right now
const event = await discussionToEvent(item)
const result = await crosspost(event, failedRelays || relays)
noteId = result.noteId
failedRelays = result.failedRelays.map(relayObj => relayObj.relay)
if (failedRelays.length > 0) {
@ -73,6 +76,6 @@ export default function useCrossposter () {
}
} while (failedRelays.length > 0)
return { allSuccessful }
return { allSuccessful, noteId }
}, [relays, toast])
}

View File

@ -24,6 +24,7 @@ export const ITEM_FIELDS = gql`
boost
bounty
bountyPaidTo
noteId
path
upvotes
meSats

View File

@ -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) {
console.error('Crosspost discussion error:', error)
return { error }

View File

@ -0,0 +1,4 @@
ALTER TABLE "Item" ADD COLUMN "noteId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Item.noteId_unique" ON "Item"("noteId");

View File

@ -298,6 +298,7 @@ model Item {
otsHash String?
imgproxyUrls Json?
bounty Int?
noteId String? @unique(map: "Item.noteId_unique")
rootId Int?
bountyPaidTo Int[]
upvotes Int @default(0)