diff --git a/package.json b/package.json index 584a4806..121c678e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "busboy": "^1.6.0", "clsx": "^1.1.1", "cookie": "^0.5.0", + "crossbell.js": "^0.10.13", "dayjs": "^1.11.1", "dotenv": "^16.0.0", "ethers": "^5.6.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5436a4..7f469d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,7 @@ specifiers: clsx: ^1.1.1 cookie: ^0.5.0 cross-spawn: ^7.0.3 + crossbell.js: ^0.10.13 dayjs: ^1.11.1 dotenv: ^16.0.0 esbuild: ^0.14.39 @@ -123,6 +124,7 @@ dependencies: busboy: 1.6.0 clsx: 1.1.1 cookie: 0.5.0 + crossbell.js: 0.10.13 dayjs: 1.11.3 dotenv: 16.0.1 ethers: 5.6.9 @@ -3144,6 +3146,23 @@ packages: which: 2.0.2 dev: true + /crossbell.js/0.10.13: + resolution: {integrity: sha512-D+uc+oP6P4sr1MOTFCZDjjrNzMEYCUytGSVAo6D0TcCiVVPqNoaaAJhzGILosmXUHlLZxbqam9zk/zfEJ0PrHg==} + dependencies: + async-retry: 1.3.3 + ethers: 5.6.9 + fetch-undici: 3.0.1 + is-ipfs: 6.0.2 + query-string: 7.1.1 + ts-mixer: 6.0.1 + web3-providers-ws: 1.7.4 + transitivePeerDependencies: + - bufferutil + - node-fetch + - supports-color + - utf-8-validate + dev: false + /crossbell.js/0.10.2: resolution: {integrity: sha512-FtoxTeinXcFjLbB5ya/NwyJX3d0VNbeOlj0fCNCpdiOf7+bibQWMkjdhnKpIieDYqt6i2dNfMbeygxhIHLWZkw==} dependencies: diff --git a/src/components/icons/CollectIcon.tsx b/src/components/icons/CollectIcon.tsx new file mode 100644 index 00000000..7d9aa639 --- /dev/null +++ b/src/components/icons/CollectIcon.tsx @@ -0,0 +1,18 @@ +import React from "react" + +export const CollectIcon: React.FC> = ( + props, +) => { + return ( + + + + ) +} diff --git a/src/components/icons/LikeIcon.tsx b/src/components/icons/LikeIcon.tsx new file mode 100644 index 00000000..2862616d --- /dev/null +++ b/src/components/icons/LikeIcon.tsx @@ -0,0 +1,21 @@ +import React from "react" + +export const LikeIcon: React.FC> = ( + props, +) => { + return ( + + + + ) +} diff --git a/src/components/site/PostFooter.tsx b/src/components/site/PostFooter.tsx new file mode 100644 index 00000000..fa7d4128 --- /dev/null +++ b/src/components/site/PostFooter.tsx @@ -0,0 +1,96 @@ +import { Note } from "~/lib/types" +import { LikeIcon } from "~/components/icons/LikeIcon" +import { CollectIcon } from "~/components/icons/CollectIcon" +import { + useLikePage, + useUnlikePage, + useGetLikes, + useCheckLike, +} from "~/queries/page" +import { useAccount } from "wagmi" +import { useConnectModal } from "@rainbow-me/rainbowkit" +import { useState, useEffect } from "react" +import { Button } from "../ui/Button" + +export const PostFooter: React.FC<{ + page?: Note +}> = ({ page }) => { + const likePage = useLikePage() + const unlikePage = useUnlikePage() + const { address } = useAccount() + const { openConnectModal } = useConnectModal() + const [likeProgress, setLikeProgress] = useState(false) + + const like = async () => { + if (!address) { + setLikeProgress(true) + openConnectModal?.() + } else if (page?.id) { + if (isLike.data?.count) { + unlikePage.mutate({ + address, + pageId: page?.id, + }) + } else { + likePage.mutate({ + address, + pageId: page?.id, + }) + } + } + } + + const likes = useGetLikes({ + pageId: page?.id, + }) + const isLike = useCheckLike({ + address, + pageId: page?.id, + }) + + useEffect(() => { + if (likeProgress && address && isLike.isSuccess && page?.id) { + if (!isLike.data.count) { + likePage.mutate({ + address, + pageId: page?.id, + }) + } + setLikeProgress(false) + } + }, [ + likeProgress, + address, + isLike.isSuccess, + isLike.data?.count, + likePage, + page?.id, + ]) + + return ( +
+ + +
+ ) +} diff --git a/src/components/site/PostMeta.tsx b/src/components/site/PostMeta.tsx index 408c4c80..611b3663 100644 --- a/src/components/site/PostMeta.tsx +++ b/src/components/site/PostMeta.tsx @@ -1,40 +1,11 @@ import { formatDate } from "~/lib/date" -import { getUserContentsUrl } from "~/lib/user-contents" -import { Avatar } from "../ui/Avatar" - -type Author = { - id: string - name: string - avatar: string | null -} - -export const PostAuthors: React.FC<{ authors: Author[] }> = ({ authors }) => { - return ( -
- {authors.map((author) => { - return ( - - - {author.name} - - ) - })} -
- ) -} export const PostMeta: React.FC<{ publishedAt: string - authors: Author[] -}> = ({ authors, publishedAt }) => { +}> = ({ publishedAt }) => { return (
{formatDate(publishedAt)} -
) } diff --git a/src/components/site/SiteHeader.tsx b/src/components/site/SiteHeader.tsx index be37ce6d..82e90cb6 100644 --- a/src/components/site/SiteHeader.tsx +++ b/src/components/site/SiteHeader.tsx @@ -88,17 +88,13 @@ export const SiteHeader: React.FC<{ }) useEffect(() => { - if ( - followProgress && - address && - subscription.isSuccess && - !subscription.data && - site?.username - ) { - subscribeToSite.mutate({ - userId: address, - siteId: site.username, - }) + if (followProgress && address && subscription.isSuccess && site?.username) { + if (!subscription.data) { + subscribeToSite.mutate({ + userId: address, + siteId: site.username, + }) + } setFollowProgress(false) } }, [ diff --git a/src/components/site/SitePage.tsx b/src/components/site/SitePage.tsx index 81394c1b..22d4654d 100644 --- a/src/components/site/SitePage.tsx +++ b/src/components/site/SitePage.tsx @@ -2,6 +2,7 @@ import type { PageType } from "~/lib/db.server" import { Rendered } from "~/markdown" import { PageContent } from "../common/PageContent" import { PostMeta } from "./PostMeta" +import { PostFooter } from "./PostFooter" import { Profile, Note } from "~/lib/types" export const SitePage: React.FC<{ @@ -17,21 +18,11 @@ export const SitePage: React.FC<{

{page?.title}

)} {page?.tags?.includes("post") && ( - + )} - + + ) } diff --git a/src/css/prose.css b/src/css/prose.css index 105cf072..f0545f18 100644 --- a/src/css/prose.css +++ b/src/css/prose.css @@ -10,6 +10,10 @@ @apply mt-0; } +.prose > *:last-child { + @apply mb-0; +} + .prose { @apply text-lg; } diff --git a/src/css/tailwind.css b/src/css/tailwind.css index e72878ae..62f9dce9 100644 --- a/src/css/tailwind.css +++ b/src/css/tailwind.css @@ -87,9 +87,9 @@ body { } .button.is-text { - @apply shadow-none; - @apply text-gray-500; - @apply hover:bg-gray-50 focus:bg-gray-100; + @apply shadow-none p-0; + @apply text-gray-500 fill-gray-500; + @apply hover:text-gray-600 hover:fill-gray-600; } .button.is-green { diff --git a/src/lib/crossbell.ts b/src/lib/crossbell.ts new file mode 100644 index 00000000..fd339e8b --- /dev/null +++ b/src/lib/crossbell.ts @@ -0,0 +1,12 @@ +import { Contract, Indexer } from "crossbell.js" + +const indexer = new Indexer() +const contract = + typeof window !== "undefined" + ? new Contract((window).ethereum) + : undefined +if (contract) { + contract.connect() +} + +export { indexer, contract } diff --git a/src/models/page.model.ts b/src/models/page.model.ts index 59712252..c33ce758 100644 --- a/src/models/page.model.ts +++ b/src/models/page.model.ts @@ -13,6 +13,7 @@ import { PageVisibilityEnum, Notes } from "~/lib/types" import { isUUID } from "~/lib/uuid" import { stripHTML } from "~/lib/utils" import unidata from "~/lib/unidata" +import { indexer, contract } from "~/lib/crossbell" const checkPageSlug = async ({ slug, @@ -93,31 +94,6 @@ export async function createOrUpdatePage(input: { ) } -export async function scheduleEmailForPost( - gate: Gate, - input: { pageId: string; emailSubject?: string }, -) { - // const page = await getPage(gate, { page: input.pageId }) - // if (!gate.allows({ type: "can-update-page", siteId: page.siteId })) { - // throw gate.permissionError() - // } - // if (page.emailStatus) { - // throw new Error("Email already scheduled or sent") - // } - // await prismaPrimary.page.update({ - // where: { - // id: page.id, - // }, - // data: { - // emailStatus: "PENDING", - // emailSubject: input.emailSubject, - // }, - // }) - // await notifySubscribersForNewPost(gate, { - // pageId: page.id, - // }) -} - export async function getPagesBySite(input: { site?: string type: "post" | "page" @@ -313,69 +289,77 @@ export async function getPage(input: { return page } -export const notifySubscribersForNewPost = async ( - gate: Gate, - input: { - pageId: string - }, -) => { - // const page = await getPage(gate, { page: input.pageId, render: true }) - // const site = await getSite(page.siteId) - // if (page.emailStatus !== PageEmailStatus.PENDING) { - // return - // } - // if (!page.published || page.publishedAt > new Date()) { - // return - // } - // if (page.type === "PAGE") { - // throw new Error("You can only notify subscribers for post updates") - // } - // if (!gate.allows({ type: "can-notify-site-subscribers", site })) { - // throw gate.permissionError() - // } - // await prismaPrimary.page.update({ - // where: { - // id: page.id, - // }, - // data: { - // emailStatus: PageEmailStatus.RUNNING, - // }, - // }) - // const memberships = await prismaPrimary.membership.findMany({ - // where: { - // role: MembershipRole.SUBSCRIBER, - // siteId: site.id, - // }, - // include: { - // user: true, - // }, - // }) - // const emailSubscribers = memberships - // .filter((member) => (member.config as any).email) - // .map((member) => member.user) - // try { - // await sendEmailForNewPost({ - // post: page, - // site, - // subscribers: emailSubscribers, - // }) - // await prismaPrimary.page.update({ - // where: { - // id: page.id, - // }, - // data: { - // emailStatus: PageEmailStatus.SUCCESS, - // }, - // }) - // } catch (error) { - // await prismaPrimary.page.update({ - // where: { - // id: page.id, - // }, - // data: { - // emailStatus: PageEmailStatus.FAILED, - // }, - // }) - // console.error("failed to send email", error) - // } +async function getPrimaryCharacter(address: string) { + const character = await indexer.getPrimaryCharacter(address) + return character?.characterId +} + +export async function likePage({ + address, + pageId, +}: { + address: string + pageId: string +}) { + const characterId = await getPrimaryCharacter(address) + if (!characterId) { + throw notFound(`character not found`) + } else { + return contract?.linkNote( + characterId, + pageId.split("-")[0], + pageId.split("-")[1], + "like", + ) + } +} + +export async function unlikePage({ + address, + pageId, +}: { + address: string + pageId: string +}) { + const characterId = await getPrimaryCharacter(address) + if (!characterId) { + throw notFound(`character not found`) + } else { + return contract?.unlinkNote( + characterId, + pageId.split("-")[0], + pageId.split("-")[1], + "like", + ) + } +} + +export async function getLikes({ pageId }: { pageId: string }) { + return indexer.getBacklinksOfNote( + pageId.split("-")[0], + pageId.split("-")[1], + { + linkType: "like", + limit: 0, + }, + ) +} + +export async function checkLike({ + address, + pageId, +}: { + address: string + pageId: string +}) { + const characterId = await getPrimaryCharacter(address) + if (!characterId) { + throw notFound(`character not found`) + } else { + return indexer.getLinks(characterId, { + linkType: "like", + toCharacterId: pageId.split("-")[0], + toNoteId: pageId.split("-")[1], + }) + } } diff --git a/src/queries/page.ts b/src/queries/page.ts index 75734630..abf6e62f 100644 --- a/src/queries/page.ts +++ b/src/queries/page.ts @@ -16,6 +16,35 @@ export const useGetPage = (input: Parameters[0]) => { }) } +export const useGetLikes = (input: { pageId?: string }) => { + return useQuery(["getLikes", input.pageId], async () => { + if (!input.pageId) { + return { + count: 0, + list: [], + } + } + return pageModel.getLikes({ + pageId: input.pageId, + }) + }) +} + +export const useCheckLike = (input: { address?: string; pageId?: string }) => { + return useQuery(["checkLike", input.pageId, input.address], async () => { + if (!input.pageId || !input.address) { + return { + count: 0, + list: [], + } + } + return pageModel.checkLike({ + address: input.address, + pageId: input.pageId, + }) + }) +} + export function useCreateOrUpdatePage() { const queryClient = useQueryClient() const mutation = useMutation( @@ -46,3 +75,41 @@ export function useDeletePage() { }, ) } + +export function useLikePage() { + const queryClient = useQueryClient() + return useMutation( + async (input: Parameters[0]) => { + return pageModel.likePage(input) + }, + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries([ + "checkLike", + variables.pageId, + variables.address, + ]) + queryClient.invalidateQueries(["getLikes", variables.pageId]) + }, + }, + ) +} + +export function useUnlikePage() { + const queryClient = useQueryClient() + return useMutation( + async (input: Parameters[0]) => { + return pageModel.unlikePage(input) + }, + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries([ + "checkLike", + variables.pageId, + variables.address, + ]) + queryClient.invalidateQueries(["getLikes", variables.pageId]) + }, + }, + ) +}