feat: like posts

This commit is contained in:
DIYgod 2022-08-06 21:24:50 +01:00
parent 34105452f9
commit 3da5ada3e3
No known key found for this signature in database
GPG Key ID: D159328F47A80DCA
13 changed files with 327 additions and 147 deletions

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import React from "react"
export const CollectIcon: React.FC<React.ComponentPropsWithoutRef<"svg">> = (
props,
) => {
return (
<svg
width="1em"
height="1em"
{...props}
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M877.632 456.8c14.976-14.72 20.384-32.96 14.816-49.984-5.536-17.024-20.608-28.544-41.344-31.584l-190.24-27.84c-6.976-1.024-18.464-9.472-21.6-15.904l-85.12-173.696c-9.28-18.944-24.896-29.76-42.88-29.76-17.952 0-33.6 10.816-42.816 29.76l-85.12 173.696c-3.104 6.432-14.592 14.848-21.6 15.904l-190.24 27.84c-20.704 3.04-35.776 14.56-41.344 31.584-5.568 17.024-0.16 35.232 14.816 49.984l137.696 135.232c5.088 4.992 9.536 18.816 8.32 25.92l-32.48 190.912c-3.552 20.832 2.752 38.816 17.344 49.344 7.52 5.44 16.224 8.16 25.472 8.16 8.576 0 17.6-2.336 26.56-7.04l170.176-90.176c6.048-3.2 20.448-3.2 26.528 0l170.144 90.112c18.528 9.856 37.504 9.44 52.064-1.056 14.56-10.528 20.864-28.48 17.344-49.28l-32.48-190.976c-1.28-7.104 3.2-20.928 8.32-25.92l137.664-135.232z"></path>
</svg>
)
}

View File

@ -0,0 +1,21 @@
import React from "react"
export const LikeIcon: React.FC<React.ComponentPropsWithoutRef<"svg">> = (
props,
) => {
return (
<svg
width="1em"
height="1em"
{...props}
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M780.8 204.8c-83.2-44.8-179.2-19.2-243.2 44.8L512 275.2 486.4 249.6c-64-64-166.4-83.2-243.2-44.8C108.8 275.2 89.6 441.6 185.6 537.6l32 32 153.6 153.6 102.4 102.4c25.6 25.6 57.6 25.6 83.2 0l102.4-102.4 153.6-153.6 32-32C934.4 441.6 915.2 275.2 780.8 204.8z"
p-id="2929"
></path>
</svg>
)
}

View File

@ -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 (
<div className="flex fill-gray-400 text-gray-500 mt-10">
<Button
variant="text"
className="flex items-center mr-10"
isAutoWidth={true}
onClick={like}
isLoading={likePage.isLoading || unlikePage.isLoading || likeProgress}
style={
isLike.isSuccess && isLike.data.count
? {
color: "#f91880",
fill: "#f91880",
}
: {}
}
>
<LikeIcon className="mr-2 w-10 h-10" />
<span>{likes.data?.count || 0}</span>
</Button>
<Button variant="text" className="flex items-center" isAutoWidth={true}>
<CollectIcon className="mr-2 w-10 h-10" />
<span>0</span>
</Button>
</div>
)
}

View File

@ -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 (
<div className="flex items-center">
{authors.map((author) => {
return (
<span key={author.id} className="flex items-center space-x-2">
<Avatar
size={24}
images={[getUserContentsUrl(author.avatar)]}
name={author.name}
/>
<span>{author.name}</span>
</span>
)
})}
</div>
)
}
export const PostMeta: React.FC<{
publishedAt: string
authors: Author[]
}> = ({ authors, publishedAt }) => {
}> = ({ publishedAt }) => {
return (
<div className="text-zinc-400 mt-2 flex items-center space-x-5">
<span>{formatDate(publishedAt)}</span>
<PostAuthors authors={authors} />
</div>
)
}

View File

@ -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)
}
}, [

View File

@ -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<{
<h2 className="text-xl font-bold page-title">{page?.title}</h2>
)}
{page?.tags?.includes("post") && (
<PostMeta
publishedAt={page.date_published}
authors={
[
{
id: site?.username || "",
name: site?.name || "",
avatar: site?.avatars?.[0] || null,
},
] || []
}
/>
<PostMeta publishedAt={page.date_published} />
)}
</div>
<PageContent contentHTML={page?.body?.content || ""} className="my-10" />
<PageContent contentHTML={page?.body?.content || ""} className="mt-10" />
<PostFooter page={page} />
</>
)
}

View File

@ -10,6 +10,10 @@
@apply mt-0;
}
.prose > *:last-child {
@apply mb-0;
}
.prose {
@apply text-lg;
}

View File

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

12
src/lib/crossbell.ts Normal file
View File

@ -0,0 +1,12 @@
import { Contract, Indexer } from "crossbell.js"
const indexer = new Indexer()
const contract =
typeof window !== "undefined"
? new Contract((<any>window).ethereum)
: undefined
if (contract) {
contract.connect()
}
export { indexer, contract }

View File

@ -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<TRender extends boolean = false>(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],
})
}
}

View File

@ -16,6 +16,35 @@ export const useGetPage = (input: Parameters<typeof pageModel.getPage>[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<typeof pageModel.likePage>[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<typeof pageModel.unlikePage>[0]) => {
return pageModel.unlikePage(input)
},
{
onSuccess: (data, variables) => {
queryClient.invalidateQueries([
"checkLike",
variables.pageId,
variables.address,
])
queryClient.invalidateQueries(["getLikes", variables.pageId])
},
},
)
}