718 lines
17 KiB
TypeScript
718 lines
17 KiB
TypeScript
import { SiteNavigationItem, Profile } from "~/lib/types"
|
|
import { nanoid } from "nanoid"
|
|
import unidata from "~/queries/unidata.server"
|
|
import type Unidata from "unidata.js"
|
|
import type { Profiles as UniProfiles } from "unidata.js"
|
|
import { createClient, cacheExchange, fetchExchange } from "@urql/core"
|
|
import { Indexer } from "crossbell.js"
|
|
import { CharacterOperatorPermission } from "crossbell.js"
|
|
import type { useContract } from "@crossbell/contract"
|
|
import dayjs from "dayjs"
|
|
import { expandUnidataProfile } from "~/lib/expand-unit"
|
|
|
|
type Contract = ReturnType<typeof useContract>
|
|
|
|
const indexer = new Indexer()
|
|
|
|
export type GetUserSitesParams =
|
|
| {
|
|
address: string
|
|
unidata?: Unidata
|
|
}
|
|
| {
|
|
handle: string
|
|
unidata?: Unidata
|
|
}
|
|
|
|
export const getUserSites = async (params: GetUserSitesParams) => {
|
|
let profiles: UniProfiles | null = null
|
|
|
|
try {
|
|
const source = "Crossbell Profile"
|
|
const filter = { primary: true }
|
|
|
|
if ("address" in params) {
|
|
profiles = await (params.unidata || unidata).profiles.get({
|
|
source,
|
|
filter,
|
|
identity: params.address,
|
|
platform: "Ethereum",
|
|
})
|
|
}
|
|
|
|
if ("handle" in params) {
|
|
profiles = await (params.unidata || unidata).profiles.get({
|
|
source,
|
|
filter,
|
|
identity: params.handle,
|
|
platform: "Crossbell",
|
|
})
|
|
}
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
|
|
const sites: Profile[] =
|
|
profiles?.list?.map((profile) => {
|
|
expandUnidataProfile(profile)
|
|
return profile
|
|
}) ?? []
|
|
|
|
return sites.length > 0 ? sites : null
|
|
}
|
|
|
|
export type GetAccountSitesParams = {
|
|
handle: string
|
|
unidata?: Unidata
|
|
}
|
|
|
|
export const getAccountSites = (
|
|
params: GetAccountSitesParams,
|
|
): Promise<Profile[] | null> => {
|
|
return getUserSites({
|
|
handle: params.handle,
|
|
unidata: params.unidata,
|
|
})
|
|
}
|
|
|
|
export const getSite = async (input: string, customUnidata?: Unidata) => {
|
|
const profiles = await (customUnidata || unidata).profiles.get({
|
|
source: "Crossbell Profile",
|
|
identity: input,
|
|
platform: "Crossbell",
|
|
})
|
|
|
|
const site: Profile = profiles.list[0]
|
|
if (site) {
|
|
expandUnidataProfile(site)
|
|
}
|
|
|
|
return site
|
|
}
|
|
|
|
export const getSites = async (input: number[]) => {
|
|
const client = createClient({
|
|
url: "https://indexer.crossbell.io/v1/graphql",
|
|
exchanges: [cacheExchange, fetchExchange],
|
|
})
|
|
const oneMonthAgo = dayjs().subtract(15, "day").toISOString()
|
|
const result = await client
|
|
.query(
|
|
`
|
|
query getCharacters($identities: [Int!], $limit: Int) {
|
|
characters( where: { characterId: { in: $identities } }, orderBy: [{ updatedAt: desc }], take: $limit ) {
|
|
handle
|
|
characterId
|
|
metadata {
|
|
uri
|
|
content
|
|
}
|
|
}
|
|
notes( where: { characterId: { in: $identities }, createdAt: { gt: "${oneMonthAgo}" }, metadata: { is: { content: { path: "sources", array_contains: "xlog" } } } }, orderBy: [{ updatedAt: desc }] ) {
|
|
characterId
|
|
createdAt
|
|
}
|
|
}`,
|
|
{
|
|
identities: input,
|
|
},
|
|
)
|
|
.toPromise()
|
|
|
|
result.data?.characters?.forEach((site: any) => {
|
|
if (site.metadata.content) {
|
|
site.metadata.content.name = site.metadata?.content?.name || site.handle
|
|
} else {
|
|
site.metadata.content = {
|
|
name: site.handle,
|
|
}
|
|
}
|
|
|
|
site.custom_domain =
|
|
site.metadata?.content?.attributes?.find(
|
|
(a: any) => a.trait_type === "xlog_custom_domain",
|
|
)?.value || ""
|
|
})
|
|
|
|
const createdAts: {
|
|
[key: string]: string
|
|
} = {}
|
|
result.data?.notes.forEach((note: any) => {
|
|
if (!createdAts[note.characterId + ""]) {
|
|
createdAts[note.characterId + ""] = note.createdAt
|
|
}
|
|
})
|
|
const list = Object.keys(createdAts)
|
|
.map((characterId: string) => {
|
|
const character = result.data?.characters.find(
|
|
(c: any) => c.characterId === characterId,
|
|
)
|
|
|
|
return {
|
|
...character,
|
|
createdAt: createdAts[characterId],
|
|
}
|
|
})
|
|
.sort((a: any, b: any) => {
|
|
return b.createdAt > a.createdAt ? 1 : -1
|
|
})
|
|
|
|
return list
|
|
}
|
|
|
|
export const getSubscription = async (
|
|
siteId: string,
|
|
handle: string,
|
|
customUnidata?: Unidata,
|
|
) => {
|
|
const links = await (customUnidata || unidata).links.get({
|
|
source: "Crossbell Link",
|
|
identity: handle,
|
|
platform: "Crossbell",
|
|
filter: { to: siteId },
|
|
})
|
|
|
|
return !!links?.list?.length
|
|
}
|
|
|
|
export const getSiteSubscriptions = async (
|
|
data: {
|
|
siteId: string
|
|
cursor?: string
|
|
limit?: number
|
|
},
|
|
customUnidata?: Unidata,
|
|
) => {
|
|
const links = await (customUnidata || unidata).links.get({
|
|
source: "Crossbell Link",
|
|
identity: data.siteId,
|
|
platform: "Crossbell",
|
|
reversed: true,
|
|
cursor: data.cursor,
|
|
limit: data.limit,
|
|
})
|
|
|
|
links?.list.map(async (item: any) => {
|
|
item.character = item.metadata.from_raw
|
|
}) || []
|
|
|
|
return links
|
|
}
|
|
|
|
export const getSiteToSubscriptions = async (
|
|
data: {
|
|
siteId: string
|
|
cursor?: string
|
|
},
|
|
customUnidata?: Unidata,
|
|
) => {
|
|
const links = await (customUnidata || unidata).links.get({
|
|
source: "Crossbell Link",
|
|
identity: data.siteId,
|
|
platform: "Crossbell",
|
|
cursor: data.cursor,
|
|
})
|
|
|
|
links?.list.map(async (item: any) => {
|
|
item.character = item.metadata.to_raw
|
|
}) || []
|
|
|
|
return links
|
|
}
|
|
|
|
export async function updateSite(
|
|
payload: {
|
|
site: string
|
|
name?: string
|
|
description?: string
|
|
icon?: string | null
|
|
subdomain?: string
|
|
navigation?: SiteNavigationItem[]
|
|
css?: string
|
|
ga?: string
|
|
custom_domain?: string
|
|
banner?: {
|
|
address: string
|
|
mime_type: string
|
|
}
|
|
connected_accounts?: Profile["connected_accounts"]
|
|
},
|
|
customUnidata?: Unidata,
|
|
newbieToken?: string,
|
|
) {
|
|
return await (customUnidata || unidata).profiles.set(
|
|
{
|
|
source: "Crossbell Profile",
|
|
identity: payload.site,
|
|
platform: "Crossbell",
|
|
action: "update",
|
|
},
|
|
{
|
|
...(payload.name && { name: payload.name }),
|
|
...(payload.description && { bio: payload.description }),
|
|
...(payload.icon && { avatars: [payload.icon] }),
|
|
...(payload.banner && { banners: [payload.banner] }),
|
|
...(payload.subdomain && { username: payload.subdomain }),
|
|
...(payload.connected_accounts && {
|
|
connected_accounts: payload.connected_accounts,
|
|
}),
|
|
...((payload.navigation !== undefined ||
|
|
payload.css !== undefined ||
|
|
payload.ga !== undefined ||
|
|
payload.custom_domain !== undefined) && {
|
|
attributes: [
|
|
...(payload.navigation !== undefined
|
|
? [
|
|
{
|
|
trait_type: "xlog_navigation",
|
|
value: JSON.stringify(payload.navigation),
|
|
},
|
|
]
|
|
: []),
|
|
...(payload.css !== undefined
|
|
? [
|
|
{
|
|
trait_type: "xlog_css",
|
|
value: payload.css,
|
|
},
|
|
]
|
|
: []),
|
|
...(payload.ga !== undefined
|
|
? [
|
|
{
|
|
trait_type: "xlog_ga",
|
|
value: payload.ga,
|
|
},
|
|
]
|
|
: []),
|
|
...(payload.custom_domain !== undefined
|
|
? [
|
|
{
|
|
trait_type: "xlog_custom_domain",
|
|
value: payload.custom_domain,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
newbieToken,
|
|
},
|
|
)
|
|
}
|
|
|
|
export async function createSite(
|
|
address: string,
|
|
payload: { name: string; subdomain: string },
|
|
customUnidata?: Unidata,
|
|
) {
|
|
return await (customUnidata || unidata).profiles.set(
|
|
{
|
|
source: "Crossbell Profile",
|
|
identity: address,
|
|
platform: "Ethereum",
|
|
action: "add",
|
|
},
|
|
{
|
|
username: payload.subdomain,
|
|
name: payload.name,
|
|
tags: [
|
|
"navigation:" +
|
|
JSON.stringify([
|
|
{
|
|
id: nanoid(),
|
|
label: "Archives",
|
|
url: "/archives",
|
|
},
|
|
]),
|
|
],
|
|
},
|
|
)
|
|
}
|
|
|
|
export async function subscribeToSites(
|
|
input: {
|
|
user: Profile
|
|
sites: {
|
|
characterId: string
|
|
}[]
|
|
},
|
|
contract?: Contract,
|
|
) {
|
|
if (input.user.metadata?.proof) {
|
|
return contract?.linkCharactersInBatch(
|
|
input.user.metadata.proof,
|
|
input.sites.map((s) => s.characterId).filter((c) => c) as any,
|
|
[],
|
|
"follow",
|
|
)
|
|
}
|
|
}
|
|
|
|
const xLogOperatorPermissions: CharacterOperatorPermission[] = [
|
|
CharacterOperatorPermission.SET_NOTE_URI,
|
|
CharacterOperatorPermission.DELETE_NOTE,
|
|
CharacterOperatorPermission.POST_NOTE,
|
|
CharacterOperatorPermission.SET_CHARACTER_URI,
|
|
]
|
|
|
|
export async function addOperator(
|
|
input: {
|
|
characterId: number
|
|
operator: string
|
|
},
|
|
contract?: Contract,
|
|
) {
|
|
if (input.operator && input.characterId) {
|
|
return contract?.grantOperatorPermissionsForCharacter(
|
|
input.characterId,
|
|
input.operator,
|
|
xLogOperatorPermissions,
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function getOperators(input: { characterId?: number }) {
|
|
if (input.characterId) {
|
|
const result = await indexer?.getCharacterOperators(input.characterId, {
|
|
limit: 100,
|
|
})
|
|
result.list = result.list
|
|
.filter(
|
|
(o) =>
|
|
o.operator !== "0x0000000000000000000000000000000000000000" &&
|
|
o.operator !== "0x0f588318a494e4508a121a32b6670b5494ca3357" &&
|
|
o.operator !== "0xbbc2918c9003d264c25ecae45b44a846702c0e7c",
|
|
) // remove 0 and xSync
|
|
.filter((o) => {
|
|
for (const permission of xLogOperatorPermissions) {
|
|
if (!o.permissions.includes(permission)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return result
|
|
}
|
|
}
|
|
|
|
export async function isOperators(input: {
|
|
characterId: number
|
|
operator: string
|
|
}) {
|
|
if (input.characterId) {
|
|
const permissions =
|
|
(await indexer?.getCharacterOperator(input.characterId, input.operator))
|
|
?.permissions || []
|
|
for (const permission of xLogOperatorPermissions) {
|
|
if (!permissions.includes(permission)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
export async function removeOperator(
|
|
input: {
|
|
characterId: number
|
|
operator: string
|
|
},
|
|
contract?: Contract,
|
|
) {
|
|
if (input.characterId) {
|
|
return contract?.grantOperatorPermissionsForCharacter(
|
|
input.characterId,
|
|
input.operator,
|
|
[],
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function getNFTs(address: string, customUnidata?: Unidata) {
|
|
const assets = await (customUnidata || unidata).assets.get({
|
|
source: "Ethereum NFT",
|
|
identity: address,
|
|
})
|
|
return assets
|
|
}
|
|
|
|
export async function getStat({ characterId }: { characterId: string }) {
|
|
if (characterId) {
|
|
const [stat, site, subscriptions, comments, notes] = await Promise.all([
|
|
(
|
|
await fetch(
|
|
`https://indexer.crossbell.io/v1/stat/characters/${characterId}`,
|
|
)
|
|
).json(),
|
|
indexer.getCharacter(characterId),
|
|
indexer.getBacklinksOfCharacter(characterId, {
|
|
limit: 0,
|
|
}),
|
|
indexer.getNotes({
|
|
limit: 0,
|
|
toCharacterId: characterId,
|
|
}),
|
|
indexer.getNotes({
|
|
characterId,
|
|
sources: "xlog",
|
|
tags: ["post"],
|
|
limit: 0,
|
|
}),
|
|
])
|
|
return {
|
|
viewsCount: stat.viewNoteCount,
|
|
createdAt: site?.createdAt,
|
|
subscriptionCount: subscriptions?.count,
|
|
commentsCount: comments?.count,
|
|
notesCount: notes?.count,
|
|
}
|
|
}
|
|
}
|
|
|
|
const getMiraTokenDecimals = async (contract: Contract) => {
|
|
let decimals
|
|
try {
|
|
decimals = await contract.getMiraTokenDecimals()
|
|
} catch (error) {
|
|
decimals = {
|
|
data: 18,
|
|
}
|
|
}
|
|
return decimals
|
|
}
|
|
|
|
export async function tipCharacter(
|
|
input: {
|
|
fromCharacterId: string | number
|
|
toCharacterId: string | number
|
|
amount: number
|
|
noteId?: string | number
|
|
},
|
|
contract: Contract,
|
|
) {
|
|
const decimals = await getMiraTokenDecimals(contract)
|
|
if (input.noteId) {
|
|
return await contract?.tipCharacterForNote(
|
|
input.fromCharacterId,
|
|
input.toCharacterId,
|
|
input.noteId,
|
|
BigInt(input.amount) * BigInt(10) ** BigInt(decimals?.data || 18),
|
|
)
|
|
} else {
|
|
return await contract?.tipCharacter(
|
|
input.fromCharacterId,
|
|
input.toCharacterId,
|
|
BigInt(input.amount) * BigInt(10) ** BigInt(decimals?.data || 18),
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function getTips(
|
|
input: {
|
|
toCharacterId: string | number
|
|
characterId?: string | number
|
|
toNoteId?: string | number
|
|
cursor?: string
|
|
},
|
|
contract: Contract,
|
|
) {
|
|
const address = await contract.getMiraTokenAddress()
|
|
const tips = await indexer?.getTips({
|
|
characterId: input.characterId,
|
|
toNoteId: input.toNoteId,
|
|
toCharacterId: input.toCharacterId,
|
|
tokenAddress: address?.data || "0xAfB95CC0BD320648B3E8Df6223d9CDD05EbeDC64",
|
|
includeMetadata: true,
|
|
limit: 7,
|
|
cursor: input.cursor,
|
|
})
|
|
|
|
if (tips?.list?.length) {
|
|
const decimals = await getMiraTokenDecimals(contract)
|
|
tips.list = tips.list.filter((t) => {
|
|
return (
|
|
BigInt(t.amount) >=
|
|
BigInt(1) * BigInt(10) ** BigInt(decimals?.data || 18)
|
|
)
|
|
})
|
|
tips.list = tips.list.map((t) => {
|
|
return {
|
|
...t,
|
|
amount: (
|
|
BigInt(t.amount) /
|
|
BigInt(10) ** BigInt(decimals?.data || 18)
|
|
).toString(),
|
|
}
|
|
})
|
|
}
|
|
|
|
return tips
|
|
}
|
|
|
|
export type AchievementSection = {
|
|
info: {
|
|
name: string
|
|
title: string
|
|
}
|
|
groups: {
|
|
info: {
|
|
name: string
|
|
title: string
|
|
}
|
|
items: {
|
|
tokenId: number
|
|
name: string
|
|
status: "INACTIVE" | "MINTABLE" | "MINTED" | "COMING"
|
|
mintedAt: string | null
|
|
transactionHash: string | null
|
|
info: {
|
|
tokenId: number
|
|
name: string
|
|
description: string
|
|
media: string
|
|
attributes: [
|
|
{
|
|
trait_type: string
|
|
value: string
|
|
},
|
|
]
|
|
}
|
|
}[]
|
|
}[]
|
|
}
|
|
|
|
export async function getAchievements(characterId: string) {
|
|
const crossbellAchievements = (await indexer.getAchievements(characterId))
|
|
?.list as AchievementSection[] | undefined
|
|
const xLogAchievements: AchievementSection[] = [
|
|
{
|
|
info: {
|
|
name: "xlog-journey",
|
|
title: "xLog Journey",
|
|
},
|
|
groups: [
|
|
{
|
|
info: {
|
|
name: "showcase-superstar",
|
|
title: "Showcase Superstar",
|
|
},
|
|
items: [
|
|
{
|
|
info: {
|
|
attributes: [
|
|
{
|
|
trait_type: "tier",
|
|
value: "base",
|
|
},
|
|
],
|
|
description: "I am a superstar on xLog!",
|
|
media:
|
|
"ipfs://QmVnTtYC4yQ7D1eGb3Ke9NVDadovSPZeg2q2cYA6j275Um/influencer/influencer:special.png",
|
|
tokenId: 0,
|
|
name: "Showcase Superstar",
|
|
},
|
|
tokenId: 0,
|
|
name: "showcase-superstar",
|
|
status: "COMING",
|
|
mintedAt: null,
|
|
transactionHash: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
name: "mirror-xyz-migrator",
|
|
title: "Mirror.xyz Migrator",
|
|
},
|
|
items: [
|
|
{
|
|
info: {
|
|
attributes: [
|
|
{
|
|
trait_type: "tier",
|
|
value: "base",
|
|
},
|
|
],
|
|
description: "I migrated from Mirror.xyz to xLog!",
|
|
media:
|
|
"ipfs://bafybeicqfeaco6skylodk3cridjvntdxvbdaqhbtkky7exsxgdrzfp7gae",
|
|
tokenId: 0,
|
|
name: "Mirror.xyz Migrator",
|
|
},
|
|
tokenId: 0,
|
|
name: "mirror-xyz-migrator",
|
|
status: "COMING",
|
|
mintedAt: null,
|
|
transactionHash: null,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
return {
|
|
list: [...xLogAchievements, ...(crossbellAchievements || [])],
|
|
}
|
|
}
|
|
|
|
export async function mintAchievement(input: {
|
|
characterId: string
|
|
achievementId: number
|
|
}) {
|
|
return indexer.mintAchievement(input.characterId, input.achievementId)
|
|
}
|
|
|
|
export async function getMiraBalance(characterId: string, contract: Contract) {
|
|
const decimals = await getMiraTokenDecimals(contract)
|
|
const result = await contract.getMiraBalanceOfCharacter(characterId)
|
|
result.data = (
|
|
BigInt(result.data) /
|
|
BigInt(10) ** BigInt(decimals?.data || 18)
|
|
).toString()
|
|
|
|
return result
|
|
}
|
|
|
|
export async function fetchTenant(
|
|
host: string,
|
|
retries: number,
|
|
): Promise<string> {
|
|
const res = await fetch(
|
|
`https://cloudflare-dns.com/dns-query?name=_xlog-challenge.${host}&type=TXT`,
|
|
{
|
|
headers: {
|
|
accept: "application/dns-json",
|
|
},
|
|
},
|
|
)
|
|
const txt = await res.json()
|
|
if (txt.Status === 5 && retries > 0) {
|
|
console.log("retrying", host, retries - 1)
|
|
return await fetchTenant(host, retries - 1)
|
|
} else {
|
|
return txt?.Answer?.[0]?.data.replace(/^"|"$/g, "")
|
|
}
|
|
}
|
|
|
|
export async function checkDomainServer(domain: string, handle: string) {
|
|
const tenant = await fetchTenant(domain, 5)
|
|
|
|
if (!tenant || tenant !== handle) {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
export async function checkDomain(domain: string, handle: string) {
|
|
const check = await (
|
|
await fetch(`/api/check-domain?domain=${domain}&handle=${handle}`)
|
|
).json()
|
|
|
|
return check.data
|
|
}
|