461 lines
11 KiB
TypeScript
461 lines
11 KiB
TypeScript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
|
import { Agent } from '@atproto/api'
|
|
import { getNetworks } from './api'
|
|
|
|
let oauthClient: BrowserOAuthClient | null = null
|
|
let agent: Agent | null = null
|
|
let sessionDid: string | null = null
|
|
let sessionHandle: string | null = null
|
|
let currentNetworkConfig: { bsky: string; plc: string } | null = null
|
|
|
|
// Get client ID based on environment
|
|
function getClientId(): string {
|
|
const host = window.location.host
|
|
|
|
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
|
const port = window.location.port || '5173'
|
|
const redirectUri = `http://127.0.0.1:${port}/`
|
|
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent('atproto transition:generic')}`
|
|
}
|
|
|
|
return `${window.location.origin}/oauth-client-metadata.json`
|
|
}
|
|
|
|
// Set network config (call before login)
|
|
export async function setNetworkConfig(handle: string): Promise<void> {
|
|
const networks = await getNetworks()
|
|
|
|
for (const [domain, network] of Object.entries(networks)) {
|
|
if (handle.endsWith(`.${domain}`)) {
|
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
|
oauthClient = null
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check syui.ai -> syu.is
|
|
if (handle.endsWith('.syui.ai')) {
|
|
const network = networks['syu.is']
|
|
if (network) {
|
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
|
oauthClient = null
|
|
return
|
|
}
|
|
}
|
|
|
|
// Default to first network
|
|
const first = Object.values(networks)[0]
|
|
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
|
oauthClient = null
|
|
}
|
|
|
|
// Initialize OAuth client
|
|
async function initOAuthClient(): Promise<BrowserOAuthClient> {
|
|
if (oauthClient) return oauthClient
|
|
|
|
const handleResolver = currentNetworkConfig?.bsky || 'https://bsky.social'
|
|
const plcDirectoryUrl = currentNetworkConfig?.plc || 'https://plc.directory'
|
|
|
|
oauthClient = await BrowserOAuthClient.load({
|
|
clientId: getClientId(),
|
|
handleResolver,
|
|
plcDirectoryUrl,
|
|
})
|
|
|
|
return oauthClient
|
|
}
|
|
|
|
// Primary OAuth domain
|
|
const OAUTH_ORIGIN = 'https://syui.ai'
|
|
|
|
// Login with handle
|
|
export async function login(handle: string): Promise<void> {
|
|
// Redirect to primary OAuth domain if on a different domain
|
|
if (window.location.origin !== OAUTH_ORIGIN) {
|
|
window.location.href = `${OAUTH_ORIGIN}${window.location.pathname}?login=${encodeURIComponent(handle)}`
|
|
return
|
|
}
|
|
|
|
await setNetworkConfig(handle)
|
|
|
|
try {
|
|
const client = await initOAuthClient()
|
|
await client.signIn(handle, {
|
|
scope: 'atproto transition:generic'
|
|
})
|
|
} catch (e) {
|
|
console.error('Login failed:', e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// Handle OAuth callback
|
|
export async function handleCallback(): Promise<string | null> {
|
|
// Check query params first, then hash fragment
|
|
let params = new URLSearchParams(window.location.search)
|
|
|
|
if (!params.has('code') && !params.has('state')) {
|
|
// Try hash fragment
|
|
if (window.location.hash && window.location.hash.length > 1) {
|
|
params = new URLSearchParams(window.location.hash.slice(1))
|
|
}
|
|
}
|
|
|
|
if (!params.has('code') && !params.has('state')) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
// Detect network from issuer (iss param) and set config before init
|
|
const iss = params.get('iss') || ''
|
|
if (iss && !currentNetworkConfig) {
|
|
const networks = await getNetworks()
|
|
for (const [domain, network] of Object.entries(networks)) {
|
|
if (iss.includes(domain)) {
|
|
currentNetworkConfig = { bsky: network.bsky, plc: network.plc }
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const client = await initOAuthClient()
|
|
|
|
// Initialize client to restore state from storage
|
|
await client.init()
|
|
|
|
const result = await client.callback(params)
|
|
sessionDid = result.session.did
|
|
|
|
// Create agent and get handle
|
|
agent = new Agent(result.session)
|
|
try {
|
|
const profile = await agent.getProfile({ actor: sessionDid })
|
|
sessionHandle = profile.data.handle
|
|
} catch {
|
|
// Could not get handle
|
|
}
|
|
|
|
// Clear URL params and hash
|
|
window.history.replaceState({}, '', window.location.pathname)
|
|
|
|
return sessionDid
|
|
} catch (e) {
|
|
console.error('OAuth callback error:', e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Logout
|
|
export async function logout(): Promise<void> {
|
|
// Clear module state
|
|
sessionDid = null
|
|
sessionHandle = null
|
|
agent = null
|
|
oauthClient = null
|
|
currentNetworkConfig = null
|
|
|
|
// Clear all storage
|
|
sessionStorage.clear()
|
|
localStorage.clear()
|
|
|
|
// Clear IndexedDB (used by OAuth client)
|
|
try {
|
|
const databases = await indexedDB.databases()
|
|
for (const db of databases) {
|
|
if (db.name) {
|
|
indexedDB.deleteDatabase(db.name)
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// IndexedDB.databases() not supported in some browsers
|
|
console.warn('Could not clear IndexedDB:', e)
|
|
}
|
|
|
|
window.location.reload()
|
|
}
|
|
|
|
// Restore session from storage
|
|
export async function restoreSession(): Promise<string | null> {
|
|
try {
|
|
// Try to initialize with default network first
|
|
const networks = await getNetworks()
|
|
const first = Object.values(networks)[0]
|
|
currentNetworkConfig = { bsky: first.bsky, plc: first.plc }
|
|
|
|
const client = await initOAuthClient()
|
|
const result = await client.init()
|
|
|
|
if (result?.session) {
|
|
sessionDid = result.session.did
|
|
|
|
// Create agent and get handle
|
|
agent = new Agent(result.session)
|
|
try {
|
|
const profile = await agent.getProfile({ actor: sessionDid })
|
|
sessionHandle = profile.data.handle
|
|
} catch {
|
|
// Could not get handle
|
|
}
|
|
|
|
return sessionDid
|
|
}
|
|
} catch (e) {
|
|
// Silently fail - no session to restore
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Check if logged in
|
|
export function isLoggedIn(): boolean {
|
|
return sessionDid !== null
|
|
}
|
|
|
|
// Get logged in DID
|
|
export function getLoggedInDid(): string | null {
|
|
return sessionDid
|
|
}
|
|
|
|
// Get logged in handle
|
|
export function getLoggedInHandle(): string | null {
|
|
return sessionHandle
|
|
}
|
|
|
|
// Get agent
|
|
export function getAgent(): Agent | null {
|
|
return agent
|
|
}
|
|
|
|
// Create post
|
|
export async function createPost(
|
|
collection: string,
|
|
title: string,
|
|
content: string
|
|
): Promise<{ uri: string; cid: string } | null> {
|
|
if (!agent) return null
|
|
|
|
try {
|
|
const result = await agent.com.atproto.repo.createRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
record: {
|
|
$type: collection,
|
|
site: window.location.origin,
|
|
title,
|
|
content: {
|
|
$type: `${collection}#markdown`,
|
|
text: content,
|
|
},
|
|
publishedAt: new Date().toISOString(),
|
|
},
|
|
})
|
|
|
|
return { uri: result.data.uri, cid: result.data.cid }
|
|
} catch (err) {
|
|
console.error('Create post error:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Update post
|
|
export async function updatePost(
|
|
collection: string,
|
|
rkey: string,
|
|
title: string,
|
|
content: string
|
|
): Promise<{ uri: string; cid: string } | null> {
|
|
if (!agent) return null
|
|
|
|
try {
|
|
// Fetch existing record to preserve translations
|
|
let existingTranslations: unknown = undefined
|
|
let existingPublishedAt: unknown = new Date().toISOString()
|
|
try {
|
|
const existing = await agent.com.atproto.repo.getRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
})
|
|
if (existing.data.value) {
|
|
const value = existing.data.value as Record<string, unknown>
|
|
existingTranslations = value.translations
|
|
if (value.publishedAt) {
|
|
existingPublishedAt = value.publishedAt
|
|
}
|
|
}
|
|
} catch {
|
|
// Record doesn't exist, that's ok
|
|
}
|
|
|
|
const record: Record<string, unknown> = {
|
|
$type: collection,
|
|
site: window.location.origin,
|
|
title,
|
|
content: {
|
|
$type: `${collection}#markdown`,
|
|
text: content,
|
|
},
|
|
publishedAt: existingPublishedAt,
|
|
}
|
|
|
|
if (existingTranslations) {
|
|
record.translations = existingTranslations
|
|
}
|
|
|
|
const result = await agent.com.atproto.repo.putRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
record,
|
|
})
|
|
|
|
return { uri: result.data.uri, cid: result.data.cid }
|
|
} catch (err) {
|
|
console.error('Update post error:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Update chat message
|
|
export async function updateChat(
|
|
collection: string,
|
|
rkey: string,
|
|
content: string
|
|
): Promise<{ uri: string; cid: string } | null> {
|
|
if (!agent) return null
|
|
|
|
try {
|
|
// Fetch existing record to preserve translations and other fields
|
|
let existingRecord: Record<string, unknown> = {}
|
|
try {
|
|
const existing = await agent.com.atproto.repo.getRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
})
|
|
if (existing.data.value) {
|
|
existingRecord = existing.data.value as Record<string, unknown>
|
|
}
|
|
} catch {
|
|
// Record doesn't exist
|
|
throw new Error('Record not found')
|
|
}
|
|
|
|
const record: Record<string, unknown> = {
|
|
...existingRecord,
|
|
$type: collection,
|
|
content,
|
|
}
|
|
|
|
const result = await agent.com.atproto.repo.putRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
record,
|
|
})
|
|
|
|
return { uri: result.data.uri, cid: result.data.cid }
|
|
} catch (err) {
|
|
console.error('Update chat error:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Update links (ai.syui.at.link)
|
|
export async function updateLinks(
|
|
links: { service: string; username: string }[]
|
|
): Promise<{ uri: string; cid: string } | null> {
|
|
if (!agent) return null
|
|
|
|
const collection = 'ai.syui.at.link'
|
|
|
|
try {
|
|
const record = {
|
|
$type: collection,
|
|
links,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
|
|
const result = await agent.com.atproto.repo.putRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey: 'self',
|
|
record,
|
|
})
|
|
|
|
return { uri: result.data.uri, cid: result.data.cid }
|
|
} catch (err) {
|
|
console.error('Update links error:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Save migrated card data to ai.syui.card.old
|
|
export async function saveMigratedCardData(
|
|
user: {
|
|
username: string
|
|
did: string
|
|
aiten: number
|
|
planet: number
|
|
fav: number
|
|
coin: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
},
|
|
card: {
|
|
cid: string
|
|
id: number
|
|
cp: number
|
|
rare: number
|
|
unique: boolean
|
|
}[],
|
|
checksum: string
|
|
): Promise<{ uri: string; cid: string } | null> {
|
|
if (!agent) return null
|
|
|
|
const collection = 'ai.syui.card.old'
|
|
const rkey = 'self'
|
|
|
|
try {
|
|
const record = {
|
|
$type: collection,
|
|
user,
|
|
card,
|
|
checksum,
|
|
migratedAt: new Date().toISOString(),
|
|
}
|
|
|
|
const result = await agent.com.atproto.repo.putRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
record,
|
|
})
|
|
|
|
return { uri: result.data.uri, cid: result.data.cid }
|
|
} catch (err) {
|
|
console.error('Save migrated card data error:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Delete record
|
|
export async function deleteRecord(
|
|
collection: string,
|
|
rkey: string
|
|
): Promise<boolean> {
|
|
if (!agent) return false
|
|
|
|
try {
|
|
await agent.com.atproto.repo.deleteRecord({
|
|
repo: agent.assertDid,
|
|
collection,
|
|
rkey,
|
|
})
|
|
return true
|
|
} catch (err) {
|
|
console.error('Delete record error:', err)
|
|
throw err
|
|
}
|
|
}
|