xLog/src/models/page.model.ts

854 lines
19 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 { RESERVED_TAGS } from "~/lib/constants"
import { editor2Crossbell } from "~/lib/editor-converter"
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,
Language,
NoteType,
PagesSortTypes,
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 = async (input: {
characterId: number
isPost?: boolean // In order to be compatible with old drafts
type?: NoteType[]
handle?: string
}) => {
return (
await Promise.all(
getKeys([`draft-${input.characterId}-`, `draft-${input.handle}-`]).map(
async (key) => {
const page = getStorage(key)
if (
page.isPost === input.isPost ||
input.type?.includes(page.type) ||
input.type === undefined
) {
const note: ExpandedNote = Object.assign(
await expandCrossbellNote({
note: editor2Crossbell({
values: page.values,
type: page.type,
}),
disableAutofill: true,
}),
{
draftKey: key
.replace(`draft-${input.characterId}-`, "")
.replace(`draft-${input.handle}-${input.characterId}-`, ""), // In order to be compatible with old drafts
local: true,
characterId: input.characterId,
createdAt: new Date(page.date).toISOString(),
publishedAt: new Date(page.date).toISOString(),
updatedAt: new Date(page.date).toISOString(),
},
)
return note
}
},
),
)
).filter((item): item is ExpandedNote => !!item)
}
export async function getPagesBySite(input: {
characterId?: number
type: NoteType | NoteType[]
visibility?: PageVisibilityEnum
limit?: number
cursor?: string
tags?: string[]
useStat?: boolean
useHTML?: boolean
useImageDimensions?: boolean
keepBody?: boolean
handle?: string // In order to be compatible with old drafts
skipExpansion?: boolean
sortType?: PagesSortTypes
}) {
if (!input.characterId) {
return {
count: 0,
list: [],
cursor: null,
}
}
const visibility = input.visibility || PageVisibilityEnum.All
let pinnedNote: NoteEntity | null = null
let pinnedNoteId: number | null = null
if (input.type === "post" || input.type.includes("post")) {
if (!input.cursor) {
pinnedNote = await getPinnedPage(input)
pinnedNoteId = pinnedNote?.noteId || null
} else {
pinnedNoteId = await getPinnedPageId(input)
}
}
const cursorQuery = input.cursor
? `
cursor: {
note_characterId_noteId_unique: {
characterId: ${input.cursor.split("_")[0]},
noteId: ${input.cursor.split("_")[1]}
},
},
`
: ""
if (typeof input.type === "string") {
input.type = [input.type]
}
const typesQuery = input.type
?.map(
(tag) => `{
content: {
path: "tags",
array_contains: ["${tag}"]
}
}`,
)
.join(", ")
const whereQuery = `
{
characterId: {
equals: $characterId
},
noteId: {
not: {
equals: $filter
}
},
deleted: {
equals: false,
},
metadata: {
AND: [
{
content: {
path: "sources",
array_contains: "xlog"
},
},
${
input.tags
? `
{
content: {
path: "tags",
array_contains: ${JSON.stringify(input.tags)}
}
},
`
: ""
}
{
OR: [
${typesQuery}
]
}
],
},
}
`
const limit = (input.limit || 12) - (pinnedNote ? 1 : 0)
let orderBy
switch (input.sortType) {
case "hottest":
orderBy = `{
stat: {
viewDetailCount:desc
}
}`
break
case "commented":
orderBy = `{
fromNotes: {
_count: desc
}
}`
break
default:
orderBy = `{
publishedAt: {
sort: desc
}
}`
break
}
const { data } = await client
.query(
gql`
query getNotes($characterId: Int, $filter: Int, $limit: Int) {
noteCount(where: ${whereQuery}),
notes(
where: ${whereQuery},
orderBy: [${orderBy}],
take: $limit,
${cursorQuery}
) {
characterId
noteId
createdAt
metadata {
uri
content
}
${
input.useStat
? `
stat {
viewDetailCount
}
_count {
fromNotes
links
}
`
: ""
}
}
}
`,
{
characterId: input.characterId,
filter: pinnedNoteId || undefined,
limit,
},
)
.toPromise()
if (pinnedNote) {
data?.notes.unshift(pinnedNote)
}
if (pinnedNoteId) {
data.noteCount += 1
}
const notes = {
list: data?.notes || [],
count: data?.noteCount || 0,
cursor:
data?.notes?.length < limit
? null
: `${input.characterId}_${data?.notes?.[data?.notes?.length - 1]
?.noteId}`,
}
const expandedNotes = {
...notes,
pinnedNoteId: pinnedNote?.noteId,
list: await Promise.all(
notes.list.map(async (note: ExpandedNote) => {
if (input.skipExpansion) {
note.metadata.content.slug = getNoteSlug(note)
} else {
note = await expandCrossbellNote({
note,
useStat: input.useStat,
useHTML: input.useHTML,
useScore: false,
useImageDimensions: input.useImageDimensions,
})
}
if (!input.keepBody) {
delete note.metadata?.content?.content
}
return note
}),
),
}
const local = await getLocalPages({
characterId: input.characterId,
isPost: input.type[0] === "post", // In order to be compatible with old drafts
type: input.type,
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].createdAt,
)
) {
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
}
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
disableAutofill?: boolean
translateTo?: Language
}) {
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 = await 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,
disableAutofill: input.disableAutofill,
translateTo: input.translateTo,
})
}
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
url?: 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,
url: input.url,
}),
})
}
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) => !RESERVED_TAGS.includes(tag))
return result
}
export async function getPinnedPageId({
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 noteId
}
export async function getPinnedPage({ characterId }: { characterId?: number }) {
const noteId = await getPinnedPageId({ characterId })
if (noteId && characterId) {
return indexer.note.get(characterId, noteId)
} else {
return null
}
}