734 lines
18 KiB
TypeScript
734 lines
18 KiB
TypeScript
import type {
|
|
CharacterEntity,
|
|
Contract,
|
|
ListResponse,
|
|
MintedNoteEntity,
|
|
NoteEntity,
|
|
NoteMetadata,
|
|
} from "crossbell"
|
|
import type { Address } from "viem"
|
|
|
|
import { GeneralAccount } from "@crossbell/connect-kit"
|
|
import { indexer } from "@crossbell/indexer"
|
|
import { extractCharacterAttribute } from "@crossbell/util-metadata"
|
|
import { gql } from "@urql/core"
|
|
|
|
import { expandCrossbellNote } from "~/lib/expand-unit"
|
|
import { filterCommentCharacter } from "~/lib/filter-character"
|
|
import { getNoteSlug } from "~/lib/helpers"
|
|
import { checkSlugReservedWords } from "~/lib/slug-reserved-words"
|
|
import { getKeys, getStorage } from "~/lib/storage"
|
|
import { ExpandedNote, PageVisibilityEnum } from "~/lib/types"
|
|
import { client } from "~/queries/graphql"
|
|
|
|
export const PINNED_PAGE_KEY = "xlog_pinned_page"
|
|
|
|
export async function checkPageSlug(input: {
|
|
slug: string
|
|
characterId?: number
|
|
noteId?: number
|
|
}) {
|
|
if (!input.characterId) {
|
|
return "Character not found"
|
|
}
|
|
const reserved = checkSlugReservedWords(input.slug)
|
|
if (reserved) {
|
|
return reserved
|
|
} else {
|
|
try {
|
|
const page = await getPage({
|
|
slug: input.slug,
|
|
characterId: input.characterId,
|
|
})
|
|
if (page && page.noteId !== input.noteId) {
|
|
return `Slug "${input.slug}" has already been used`
|
|
}
|
|
} catch (error) {}
|
|
}
|
|
}
|
|
|
|
export async function postNotes(
|
|
input: {
|
|
siteId: string
|
|
characterId: number
|
|
notes: NoteMetadata[]
|
|
},
|
|
contract?: Contract,
|
|
) {
|
|
return contract?.note.postMany({
|
|
notes: input.notes.map((note) => ({
|
|
characterId: input.characterId,
|
|
metadataOrUri: note,
|
|
})),
|
|
})
|
|
}
|
|
|
|
const getLocalPages = (input: {
|
|
characterId: number
|
|
isPost?: boolean
|
|
handle?: string
|
|
}) => {
|
|
const pages: ExpandedNote[] = []
|
|
getKeys([`draft-${input.characterId}-`, `draft-${input.handle}-`]).forEach(
|
|
(key) => {
|
|
const page = getStorage(key)
|
|
if (input.isPost === undefined || page.isPost === input.isPost) {
|
|
const note: ExpandedNote = {
|
|
characterId: input.characterId,
|
|
noteId: 0,
|
|
draftKey: key
|
|
.replace(`draft-${input.characterId}-`, "")
|
|
.replace(`draft-${input.handle}-${input.characterId}-`, ""), // In order to be compatible with old drafts
|
|
linkItemType: null,
|
|
linkKey: "",
|
|
toCharacterId: null,
|
|
toAddress: null,
|
|
toNoteId: null,
|
|
toHeadCharacterId: null,
|
|
toHeadNoteId: null,
|
|
toContractAddress: null,
|
|
toTokenId: null,
|
|
toLinklistId: null,
|
|
toUri: null,
|
|
deleted: false,
|
|
locked: false,
|
|
contractAddress: null,
|
|
uri: null,
|
|
operator: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`.
|
|
owner: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`.
|
|
createdAt: new Date(page.date).toISOString(),
|
|
publishedAt: new Date(page.date).toISOString(),
|
|
updatedAt: new Date(page.date).toISOString(),
|
|
deletedAt: null,
|
|
transactionHash: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`.
|
|
blockNumber: 0,
|
|
logIndex: 0,
|
|
updatedTransactionHash: "" as Address, // TODO: check usage and replace it with viem's `zeroAddress`.
|
|
updatedBlockNumber: 0,
|
|
updatedLogIndex: 0,
|
|
metadata: {
|
|
content: {
|
|
title: page.values?.title,
|
|
content: page.values?.content,
|
|
date_published: page.values?.publishedAt,
|
|
summary: page.values?.excerpt,
|
|
tags: [
|
|
page.isPost ? "post" : "page",
|
|
...(page.values?.tags
|
|
?.split(",")
|
|
.map((tag: string) => tag.trim())
|
|
.filter((tag: string) => tag) || []),
|
|
],
|
|
slug: page.values?.slug,
|
|
sources: ["xlog"],
|
|
disableAISummary: page.values?.disableAISummary,
|
|
},
|
|
},
|
|
local: true,
|
|
}
|
|
pages.push(note)
|
|
}
|
|
},
|
|
)
|
|
return pages
|
|
}
|
|
|
|
export async function getPagesBySite(input: {
|
|
characterId?: number
|
|
type: "post" | "page"
|
|
visibility?: PageVisibilityEnum
|
|
limit?: number
|
|
cursor?: string
|
|
tags?: string[]
|
|
useStat?: boolean
|
|
useHTML?: boolean
|
|
keepBody?: boolean
|
|
handle?: string // In order to be compatible with old drafts
|
|
skipExpansion?: boolean
|
|
}) {
|
|
if (!input.characterId) {
|
|
return {
|
|
count: 0,
|
|
list: [],
|
|
cursor: null,
|
|
}
|
|
}
|
|
|
|
const visibility = input.visibility || PageVisibilityEnum.All
|
|
|
|
const expandedNotes = await (async (): Promise<{
|
|
list: ExpandedNote[]
|
|
pinnedNoteId?: number
|
|
count: number
|
|
cursor: string | null
|
|
}> => {
|
|
const [pinnedNote, notes] = await Promise.all([
|
|
!input.cursor ? getPinnedPage(input) : null,
|
|
|
|
await indexer.note.getMany({
|
|
characterId: input.characterId,
|
|
limit: input.limit || 12,
|
|
cursor: input.cursor,
|
|
orderBy: "publishedAt",
|
|
tags: [...(input.tags || []), input.type],
|
|
sources: "xlog",
|
|
}),
|
|
])
|
|
|
|
const list: NoteEntity[] = pinnedNote
|
|
? [
|
|
pinnedNote,
|
|
...notes.list.filter((note) => note.noteId !== pinnedNote.noteId),
|
|
]
|
|
: notes.list
|
|
|
|
return {
|
|
...notes,
|
|
pinnedNoteId: pinnedNote?.noteId,
|
|
list: await Promise.all(
|
|
list.map(async (note) => {
|
|
let expanded = note as ExpandedNote
|
|
if (input.skipExpansion) {
|
|
expanded.metadata.content.slug = getNoteSlug(expanded)
|
|
} else {
|
|
expanded = await expandCrossbellNote({
|
|
note,
|
|
useStat: input.useStat,
|
|
useHTML: input.useHTML,
|
|
useScore: false,
|
|
})
|
|
}
|
|
|
|
if (!input.keepBody) {
|
|
delete expanded.metadata?.content?.content
|
|
}
|
|
|
|
return expanded
|
|
}),
|
|
),
|
|
}
|
|
})()
|
|
|
|
const local = getLocalPages({
|
|
characterId: input.characterId,
|
|
isPost: input.type === "post",
|
|
handle: input.handle,
|
|
})
|
|
|
|
local.forEach((localPage) => {
|
|
const index = expandedNotes.list.findIndex(
|
|
(page) => localPage.draftKey === (page.noteId || page.draftKey) + "",
|
|
)
|
|
if (index !== -1) {
|
|
if (
|
|
new Date(localPage.updatedAt) >
|
|
new Date(expandedNotes.list[index].updatedAt)
|
|
) {
|
|
expandedNotes.list[index] = {
|
|
...expandedNotes.list[index],
|
|
metadata: {
|
|
content: localPage.metadata?.content,
|
|
},
|
|
local: true,
|
|
draftKey: localPage.draftKey,
|
|
}
|
|
}
|
|
} else {
|
|
expandedNotes.list.push(localPage)
|
|
expandedNotes.count++
|
|
}
|
|
})
|
|
|
|
switch (visibility) {
|
|
case PageVisibilityEnum.Published:
|
|
expandedNotes.list = expandedNotes.list.filter(
|
|
(page) =>
|
|
(!page.metadata?.content?.date_published ||
|
|
+new Date(page.metadata?.content?.date_published) <= +new Date()) &&
|
|
page.noteId,
|
|
)
|
|
break
|
|
case PageVisibilityEnum.Draft:
|
|
expandedNotes.list = expandedNotes.list.filter((page) => !page.noteId)
|
|
break
|
|
case PageVisibilityEnum.Scheduled:
|
|
expandedNotes.list = expandedNotes.list.filter(
|
|
(page) =>
|
|
page.metadata?.content?.date_published &&
|
|
+new Date(page.metadata?.content?.date_published) > +new Date(),
|
|
)
|
|
break
|
|
}
|
|
|
|
expandedNotes.list.sort((a, b) => {
|
|
if (
|
|
!a.metadata?.content?.date_published ||
|
|
a.noteId === expandedNotes.pinnedNoteId
|
|
) {
|
|
return -1
|
|
} else if (
|
|
!b.metadata?.content?.date_published ||
|
|
b.noteId === expandedNotes.pinnedNoteId
|
|
) {
|
|
return 1
|
|
} else {
|
|
return (
|
|
+new Date(b.metadata?.content?.date_published) -
|
|
+new Date(a.metadata?.content?.date_published)
|
|
)
|
|
}
|
|
})
|
|
|
|
return expandedNotes
|
|
}
|
|
|
|
export async function getSearchPagesBySite(input: {
|
|
characterId?: number
|
|
keyword?: string
|
|
cursor?: string
|
|
}) {
|
|
const result = await indexer.search.notes(input.keyword || "", {
|
|
cursor: input.cursor,
|
|
characterId: input.characterId,
|
|
tags: ["post"],
|
|
sources: ["xlog"],
|
|
orderBy: "publishedAt",
|
|
})
|
|
|
|
const list: ExpandedNote[] = await Promise.all(
|
|
result.list.map(async (page: any) => {
|
|
return await expandCrossbellNote({
|
|
note: page,
|
|
useStat: true,
|
|
useScore: false,
|
|
keyword: input.keyword,
|
|
})
|
|
}),
|
|
)
|
|
|
|
return {
|
|
list,
|
|
cursor: result.cursor,
|
|
count: result.count,
|
|
}
|
|
}
|
|
|
|
export async function getPage<TRender extends boolean = false>(input: {
|
|
slug?: string
|
|
characterId: number
|
|
useStat?: boolean
|
|
noteId?: number
|
|
handle?: string // In order to be compatible with old drafts
|
|
}) {
|
|
const mustLocal = input.slug?.startsWith("!local-") && !input.noteId
|
|
|
|
let page: NoteEntity | null = null
|
|
|
|
if (!mustLocal) {
|
|
if (!input.noteId) {
|
|
const result = await client
|
|
.query(
|
|
gql`
|
|
query getNotes($characterId: Int!, $slug: JSON!) {
|
|
notes(
|
|
where: {
|
|
characterId: {
|
|
equals: $characterId,
|
|
},
|
|
deleted: {
|
|
equals: false,
|
|
},
|
|
metadata: {
|
|
content: {
|
|
path: ["sources"],
|
|
array_contains: ["xlog"]
|
|
},
|
|
OR: [
|
|
{
|
|
content: {
|
|
path: ["attributes"],
|
|
array_contains: [{
|
|
trait_type: "xlog_slug",
|
|
value: $slug,
|
|
}]
|
|
}
|
|
},
|
|
{
|
|
content: {
|
|
path: ["title"],
|
|
equals: $slug
|
|
},
|
|
}
|
|
]
|
|
},
|
|
},
|
|
orderBy: [{ createdAt: desc }],
|
|
take: 1,
|
|
) {
|
|
characterId
|
|
noteId
|
|
uri
|
|
metadata {
|
|
uri
|
|
content
|
|
}
|
|
owner
|
|
createdAt
|
|
updatedAt
|
|
publishedAt
|
|
transactionHash
|
|
blockNumber
|
|
updatedTransactionHash
|
|
updatedBlockNumber
|
|
${
|
|
input.useStat
|
|
? `
|
|
stat {
|
|
viewDetailCount
|
|
}
|
|
`
|
|
: ""
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
characterId: input.characterId,
|
|
slug: decodeURIComponent(input.slug!),
|
|
},
|
|
)
|
|
.toPromise()
|
|
page = result.data.notes[0]
|
|
} else {
|
|
const result = await client
|
|
.query(
|
|
gql`
|
|
query getNote($characterId: Int!, $noteId: Int!) {
|
|
note(
|
|
where: {
|
|
note_characterId_noteId_unique: {
|
|
characterId: $characterId,
|
|
noteId: $noteId,
|
|
},
|
|
},
|
|
) {
|
|
characterId
|
|
noteId
|
|
uri
|
|
metadata {
|
|
uri
|
|
content
|
|
}
|
|
owner
|
|
createdAt
|
|
updatedAt
|
|
publishedAt
|
|
transactionHash
|
|
blockNumber
|
|
updatedTransactionHash
|
|
updatedBlockNumber
|
|
${
|
|
input.useStat
|
|
? `
|
|
stat {
|
|
viewDetailCount
|
|
}
|
|
`
|
|
: ""
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
{
|
|
characterId: input.characterId,
|
|
noteId: input.noteId,
|
|
},
|
|
)
|
|
.toPromise()
|
|
page = result.data.note
|
|
}
|
|
}
|
|
|
|
// local page
|
|
const local = getLocalPages({
|
|
characterId: input.characterId,
|
|
handle: input.handle,
|
|
})
|
|
const localPages = local.filter(
|
|
(page) =>
|
|
page.draftKey === input.noteId + "" || page.draftKey === input.slug,
|
|
)
|
|
const localPage =
|
|
localPages.length &&
|
|
localPages.reduce((prev, current) => {
|
|
return prev.updatedAt > current.updatedAt ? prev : current
|
|
})
|
|
|
|
let expandedNote: ExpandedNote | undefined
|
|
if (page) {
|
|
expandedNote = await expandCrossbellNote({
|
|
note: page,
|
|
useStat: input.useStat,
|
|
})
|
|
}
|
|
|
|
if (localPage) {
|
|
if (expandedNote) {
|
|
if (new Date(localPage.updatedAt) > new Date(expandedNote.updatedAt)) {
|
|
expandedNote = {
|
|
...expandedNote,
|
|
metadata: {
|
|
content: localPage.metadata?.content,
|
|
},
|
|
local: true,
|
|
}
|
|
}
|
|
} else {
|
|
expandedNote = localPage
|
|
}
|
|
}
|
|
|
|
return expandedNote
|
|
}
|
|
|
|
export async function reportStats(input: {
|
|
characterId: number
|
|
noteId: number
|
|
}) {
|
|
return await indexer.note.get(input.characterId, input.noteId)
|
|
}
|
|
|
|
export async function getLikes({
|
|
characterId,
|
|
noteId,
|
|
cursor,
|
|
includeCharacter,
|
|
}: {
|
|
characterId: number
|
|
noteId: number
|
|
cursor?: string
|
|
includeCharacter?: boolean
|
|
}) {
|
|
const res = await indexer.link.getBacklinksByNote(characterId, noteId, {
|
|
linkType: "like",
|
|
cursor,
|
|
})
|
|
if (includeCharacter) {
|
|
res.list?.forEach((item) => {
|
|
;(item as any).character = item.fromCharacter
|
|
})
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
export async function checkLike({
|
|
account,
|
|
characterId,
|
|
noteId,
|
|
}: {
|
|
account: GeneralAccount
|
|
characterId: number
|
|
noteId: number
|
|
}) {
|
|
if (account.characterId) {
|
|
return indexer.link.getMany(account.characterId, {
|
|
linkType: "like",
|
|
toCharacterId: characterId,
|
|
toNoteId: noteId,
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function mintPage(
|
|
{
|
|
address,
|
|
characterId,
|
|
noteId,
|
|
}: {
|
|
address: string
|
|
characterId: number
|
|
noteId: number
|
|
},
|
|
contract?: Contract,
|
|
) {
|
|
return contract?.note.mint({
|
|
characterId,
|
|
noteId,
|
|
toAddress: address as Address,
|
|
})
|
|
}
|
|
|
|
export async function getMints({
|
|
characterId,
|
|
noteId,
|
|
cursor,
|
|
includeCharacter,
|
|
}: {
|
|
characterId: number
|
|
noteId: number
|
|
cursor?: string
|
|
includeCharacter?: boolean
|
|
}) {
|
|
const data = await indexer.mintedNote.getManyOfNote(characterId, noteId, {
|
|
cursor,
|
|
limit: 5,
|
|
})
|
|
|
|
if (includeCharacter) {
|
|
await Promise.all(
|
|
data.list.map(async (item: any) => {
|
|
if (!item.character) {
|
|
item.character = await indexer.character.getPrimary(item.owner)
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
return data as ListResponse<
|
|
MintedNoteEntity & {
|
|
character: CharacterEntity
|
|
}
|
|
>
|
|
}
|
|
|
|
export async function checkMint({
|
|
address,
|
|
noteCharacterId,
|
|
noteId,
|
|
}: {
|
|
address: string
|
|
noteCharacterId: number
|
|
noteId: number
|
|
}) {
|
|
return indexer.mintedNote.getManyOfAddress(address as Address, {
|
|
noteCharacterId: noteCharacterId,
|
|
noteId: noteId,
|
|
})
|
|
}
|
|
|
|
export async function checkComment({
|
|
characterId,
|
|
noteCharacterId,
|
|
noteId,
|
|
}: {
|
|
characterId: number
|
|
noteCharacterId: number
|
|
noteId: number
|
|
}) {
|
|
return indexer.note.getMany({
|
|
characterId: characterId,
|
|
toCharacterId: noteCharacterId,
|
|
toNoteId: noteId,
|
|
limit: 0,
|
|
})
|
|
}
|
|
|
|
export async function getComments({
|
|
characterId,
|
|
noteId,
|
|
cursor,
|
|
}: {
|
|
characterId: number
|
|
noteId: number
|
|
cursor?: string
|
|
}) {
|
|
const options = {
|
|
toCharacterId: characterId,
|
|
toNoteId: noteId,
|
|
cursor,
|
|
includeCharacter: true,
|
|
includeNestedNotes: true,
|
|
nestedNotesDepth: 3 as 3,
|
|
nestedNotesLimit: 20,
|
|
limit: 5,
|
|
}
|
|
|
|
const res = (await indexer.note.getMany(options)) || {
|
|
total: 0,
|
|
list: [],
|
|
}
|
|
|
|
res.list = res.list.filter(filterCommentCharacter)
|
|
|
|
return res
|
|
}
|
|
|
|
export async function anonymousComment(input: {
|
|
targetCharacterId: number
|
|
targetNoteId: number
|
|
content: string
|
|
name: string
|
|
email: string
|
|
}) {
|
|
return await fetch("/api/anonymous/comment", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
targetCharacterId: input.targetCharacterId,
|
|
targetNoteId: input.targetNoteId,
|
|
content: input.content,
|
|
name: input.name,
|
|
email: input.email,
|
|
}),
|
|
})
|
|
}
|
|
|
|
export async function updateComment(
|
|
{
|
|
content,
|
|
characterId,
|
|
noteId,
|
|
}: {
|
|
content: string
|
|
characterId: number
|
|
noteId: number
|
|
},
|
|
contract: Contract,
|
|
) {
|
|
return contract.note.setMetadata({
|
|
characterId,
|
|
noteId,
|
|
metadata: {
|
|
content,
|
|
tags: ["comment"],
|
|
sources: ["xlog"],
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function checkMirror(characterId: number) {
|
|
const notes = await indexer.note.getMany({
|
|
characterId,
|
|
sources: "xlog",
|
|
tags: ["post", "Mirror.xyz"],
|
|
limit: 0,
|
|
})
|
|
|
|
return notes.count === 0
|
|
}
|
|
|
|
export const getDistinctNoteTagsOfCharacter = async (characterId: number) => {
|
|
const result = await indexer.note.getDistinctTagsOfCharacter(characterId, {
|
|
sources: "xlog",
|
|
})
|
|
result.list = result.list.filter(
|
|
(tag) => ["post", "comment", "page"].findIndex((t) => t === tag) === -1,
|
|
)
|
|
|
|
return result
|
|
}
|
|
|
|
export async function getPinnedPage({ characterId }: { characterId?: number }) {
|
|
if (!characterId) return null
|
|
|
|
const character = await indexer.character.get(characterId)
|
|
const noteId = extractCharacterAttribute(character, PINNED_PAGE_KEY)?.value
|
|
|
|
if (!character || !noteId || typeof noteId !== "number") return null
|
|
|
|
return indexer.note.get(character.characterId, noteId)
|
|
}
|