diff --git a/api/functions/nostr-confirm-event/nostr-confirm-event.js b/api/functions/nostr-confirm-event/nostr-confirm-event.js index 6b8faea..bd522af 100644 --- a/api/functions/nostr-confirm-event/nostr-confirm-event.js +++ b/api/functions/nostr-confirm-event/nostr-confirm-event.js @@ -27,11 +27,13 @@ const signEvent = async (req, res) => { return res.status(400).send("Signature not valid") + // Extract type & id const rTag = event.tags.find(tag => tag[0] === 'r'); const [host, type, refId] = rTag[1].split(' '); - if (host !== 'boltfun') res.status(400).send("This event wasn't signed by bolt.fun"); + if (host !== 'boltfun') return res.status(400).send("This event wasn't signed by bolt.fun"); + if (type === 'Story_comment') { @@ -48,6 +50,7 @@ const signEvent = async (req, res) => { }))?.id; } + // Insert comment in database await prisma.postComment.create({ data: { @@ -59,10 +62,14 @@ const signEvent = async (req, res) => { }, }) + + } + return res .status(200) + .end() } catch (error) { console.log(error); res.status(500).send("Unexpected error happened, please try again") diff --git a/src/features/Posts/Components/Comments/CommentsSection/CommentsSection.tsx b/src/features/Posts/Components/Comments/CommentsSection/CommentsSection.tsx index c0b5201..97a5457 100644 --- a/src/features/Posts/Components/Comments/CommentsSection/CommentsSection.tsx +++ b/src/features/Posts/Components/Comments/CommentsSection/CommentsSection.tsx @@ -7,6 +7,7 @@ import { useAppSelector } from "src/utils/hooks"; import * as CommentsWorker from './comments.worker' import { Post_Type } from 'src/graphql' +import useComments from './useComments' // const createWorker = createWorkerFactory(() => import('./comments.worker')); @@ -21,24 +22,25 @@ export default function CommentsSection({ type, id }: Props) { // const worker = useWorker(createWorker); // const commentsTree = useMemo(() => convertCommentsToTree(comments), [comments]) - const [commentsTree, setCommentsTree] = useState([]) + // const [commentsTree, setCommentsTree] = useState([]) const user = useAppSelector(state => state.user.me) - const filter = useMemo(() => `boltfun ${type}_comment ${id}` + (process.env.NODE_ENV === 'development' ? ' dev' : ""), [id, type]) + // const filter = useMemo(() => `boltfun ${type}_comment ${id}` + (process.env.NODE_ENV === 'development' ? ' dev' : ""), [id, type]) - useEffect(() => { - CommentsWorker.connect(); - const unsub = CommentsWorker.sub(filter, (newComments) => { - setCommentsTree(newComments) - }) + // useEffect(() => { + // CommentsWorker.connect(); + // const unsub = CommentsWorker.sub(filter, (newComments) => { + // setCommentsTree(newComments) + // }) - return () => { - unsub(); - } - }, [filter]); + // return () => { + // unsub(); + // } + // }, [filter]); + const { commentsTree, postComment } = useComments({ type, id }) const handleNewComment = async (content: string, parentId?: string) => { try { - await CommentsWorker.post({ content, filter, parentId }); + await postComment({ content, parentId }); return true; } catch (error) { return false @@ -63,7 +65,7 @@ export default function CommentsSection({ type, id }: Props) { comment={comment} isRoot canReply={!!user} - onReply={content => handleNewComment(content, comment.id.toString())} + onReply={content => handleNewComment(content, comment.nostr_id.toString())} />)} diff --git a/src/features/Posts/Components/Comments/CommentsSection/useComments.tsx b/src/features/Posts/Components/Comments/CommentsSection/useComments.tsx new file mode 100644 index 0000000..d1efe6f --- /dev/null +++ b/src/features/Posts/Components/Comments/CommentsSection/useComments.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { relayPool } from 'nostr-tools' +import { Nullable } from 'remirror'; +import { CONSTS } from 'src/utils'; +import { Comment } from "../types"; +import { useDebouncedState } from "@react-hookz/web"; +import { Post_Type } from "src/graphql"; + +const pool = relayPool(); + +const useComments = (config: { + type: Post_Type, + id: string | number; + +}) => { + const commentsEventsTemp = useRef>>({}) + const [commentsEvents, setCommentsEvents] = useDebouncedState>>({}, 1000) + const pendingResolvers = useRef void>>({}); + const filter = useMemo(() => `boltfun ${config.type}_comment ${config.id}` + (process.env.NODE_ENV === 'development' ? ' dev' : ""), [config.id, config.type]) + + const [commentsTree, setCommentsTree] = useState([]) + + + useEffect(() => { + connect(); + let sub = pool.sub({ + filter: { + "#r": [filter] + }, + cb: async (event: Required) => { + //Got a new event + if (!event.id) return; + + if (event.id in commentsEventsTemp.current) return; + + commentsEventsTemp.current[event.id] = event; + + setCommentsEvents({ ...commentsEventsTemp.current }) + } + }); + + return () => { + sub.unsub(); + }; + }, [filter, setCommentsEvents]); + + useEffect(() => { + (async () => { + const newTree = await buildTree(commentsEvents); + setCommentsTree(newTree); + Object.entries(pendingResolvers.current).forEach(([id, resolve]) => { + if (id in commentsEvents) { + delete pendingResolvers.current[id]; + resolve(); + } + }); + })(); + }, [commentsEvents]) + + const postComment = useCallback(async ({ content, parentId }: { + content: string, + parentId?: string + }) => { + + const tags = []; + tags.push(['r', filter]); + if (parentId) + tags.push(['e', `${parentId} ${CONSTS.DEFAULT_RELAYS[0]} reply`]) + + let event: NostrEvent; + try { + event = await signEvent({ + kind: 1, + tags, + content, + }) as NostrEvent; + } catch (error) { + alert("Couldn't sign the object successfully...") + return; + } + + return new Promise((resolve, reject) => { + let confirmationSent = false; + + pool.publish(event, (status: number, relay: string) => { + switch (status) { + case -1: + console.log(`failed to send ${JSON.stringify(event)} to ${relay}`) + break + case 1: + clearTimeout(publishTimeout) + console.log(`event ${event.id?.slice(0, 5)}… published to ${relay}.`) + if (!confirmationSent) { + confirmPublishingEvent(event) + confirmationSent = true; + } + break + } + }); + + pendingResolvers.current[event.id!] = resolve; + + const publishTimeout = setTimeout(() => { + delete pendingResolvers.current[event.id!] + reject("Failed to publish to any relay..."); + }, 5000) + + + }) + }, [filter]) + + + return { commentsTree, postComment } +} + +export default useComments; + +function connect() { + CONSTS.DEFAULT_RELAYS.forEach(url => { + pool.addRelay(url, { read: true, write: true }) + }) + pool.onNotice((notice: string, relay: any) => { + console.log(`${relay.url} says: ${notice}`) + }) +}; + + +function extractParentId(event: NostrEvent): Nullable { + + for (const [identifier, value] of event.tags) { + if (identifier === 'e') { + const [eventId, , marker] = value.split(' '); + if (marker === 'reply') return eventId; + } + } + return null; +} + + +async function signEvent(event: any) { + const res = await fetch(CONSTS.apiEndpoint + '/nostr-sign-event', { + method: "post", + body: JSON.stringify({ event }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + }); + const data = await res.json() + return data.event; +} + +async function confirmPublishingEvent(event: any) { + const res = await fetch(CONSTS.apiEndpoint + '/nostr-confirm-event', { + method: "post", + body: JSON.stringify({ event }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + }); + const data = await res.json() + return data.event; +} + + +async function getCommentsExtraData(ids: string[]) { + const res = await fetch(CONSTS.apiEndpoint + '/nostr-events-extra-data', { + method: "post", + body: JSON.stringify({ ids }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + }); + + type EventExtraData = { + id: number + nostr_id: string + votes_count: number + user: { + id: number, + avatar: string, + name: string, + } + } + + const data = await res.json() as EventExtraData[]; + + const map = new Map() + data.forEach(item => { + map.set(item.nostr_id, item) + }); + return map; +} + +async function buildTree(events: Record>) { + + // Sort them chronologically from oldest to newest + let sortedEvenets = Object.values(events).sort((a, b) => a.created_at - b.created_at); + + + // Extract the pubkeys used + const pubkeysSet = new Set(); + sortedEvenets.forEach(e => pubkeysSet.add(e.pubkey)); + + // Make a request to api to get comments extra data + const commentsExtraData = await getCommentsExtraData(Object.keys(events)); + + let eventsTree: Record = {} + // If event is a reply, connect it to parent + sortedEvenets.forEach(e => { + const parentId = extractParentId(e); + const extraData = commentsExtraData.get(e.id); + + // if no extra data is here then that means this event wasn't done from our platform + if (!extraData) return; + + if (parentId) { + eventsTree[parentId]?.replies.push({ + id: extraData.id, + nostr_id: e.id, + body: e.content, + created_at: e.created_at * 1000, + pubkey: e.pubkey, + author: extraData.user, + replies: [], + votes_count: extraData.votes_count + }); + } else { + eventsTree[e.id] = ({ + id: extraData.id, + nostr_id: e.id, + body: e.content, + created_at: e.created_at * 1000, + pubkey: e.pubkey, + author: extraData.user, + replies: [], + votes_count: extraData.votes_count + + }); + } + }) + + // Run the censoring service + // (nothing for now -:-) + + // Turn the top roots replies into a sorted array + const sortedTree = Object.values(eventsTree).sort((a, b) => b.created_at - a.created_at) + + return sortedTree; +} \ No newline at end of file diff --git a/src/features/Posts/pages/PostDetailsPage/Components/PageContent/PageContent.skeleton.tsx b/src/features/Posts/pages/PostDetailsPage/Components/PageContent/PageContent.skeleton.tsx index f12daef..e5ac08c 100644 --- a/src/features/Posts/pages/PostDetailsPage/Components/PageContent/PageContent.skeleton.tsx +++ b/src/features/Posts/pages/PostDetailsPage/Components/PageContent/PageContent.skeleton.tsx @@ -13,7 +13,7 @@ export default function PageContentSkeleton() {
- {Array(3).fill(0).map(i => + {Array(3).fill(0).map((_, idx) =>
hidden
)}