import { Client } from '@atcute/client'; import type {} from '@atcute/atproto'; import { createAuthorizationUrl, finalizeAuthorization, OAuthUserAgent, } from '@atcute/oauth-browser-client'; import { getConfig } from './config'; // Actor typeahead web component import 'actor-typeahead'; // Global agent instance let globalAgent: OAuthUserAgent | null = null; // Helper function to wait const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** * Web component for signing the guestbook. * * Usage: * */ export class GuestbookSignElement extends HTMLElement { private form: HTMLFormElement | null = null; private messageInput: HTMLTextAreaElement | null = null; private submitButton: HTMLButtonElement | null = null; private statusDiv: HTMLDivElement | null = null; private loginInput: HTMLInputElement | null = null; private loginButton: HTMLButtonElement | null = null; private userHandle: string | null = null; static get observedAttributes() { return ['did']; } constructor() { super(); this.attachShadow({ mode: 'open' }); } async connectedCallback() { await this.initializeAuth(); if (globalAgent) { await this.fetchUserHandle(); } this.render(); this.attachEventListeners(); } private async fetchUserHandle() { if (!globalAgent) return; try { const response = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${globalAgent.session.info.sub}` ); if (response.ok) { const data = await response.json(); this.userHandle = data.handle || null; } } catch (error) { console.error('Failed to fetch user handle:', error); } } private async initializeAuth() { // Check if we have OAuth parameters in the hash (returning from OAuth) if (location.hash.length > 1) { const params = new URLSearchParams(location.hash.slice(1)); if (params.has('state') && (params.has('code') || params.has('error'))) { try { // Scrub the parameters from history to prevent replay history.replaceState(null, '', location.pathname + location.search); // Finalize the authorization and get the result const result = await finalizeAuthorization(params); // Create the OAuth user agent with the session globalAgent = new OAuthUserAgent(result.session); console.log('Authorization successful! Logged in as:', result.session.info.sub); } catch (error) { console.error('Failed to finalize authorization:', error); alert('Login failed. Please try again.'); } } } } private render() { if (!this.shadowRoot) { return; } const isLoggedIn = globalAgent !== null; this.shadowRoot.innerHTML = `
${!isLoggedIn ? `
` : `
`}
`; if (isLoggedIn) { this.form = this.shadowRoot.querySelector('#sign-form'); this.messageInput = this.shadowRoot.querySelector('#message'); this.submitButton = this.shadowRoot.querySelector('#submit-btn'); } else { this.form = this.shadowRoot.querySelector('#login-form'); this.loginInput = this.shadowRoot.querySelector('#handle'); this.loginButton = this.shadowRoot.querySelector('#login-btn'); } this.statusDiv = this.shadowRoot.querySelector('#status'); } private attachEventListeners() { if (!this.form) { return; } const isLoggedIn = globalAgent !== null; if (isLoggedIn && this.messageInput) { // character counter this.messageInput.addEventListener('input', () => { this.updateCharCount(); }); // form submission for signing this.form.addEventListener('submit', (e) => { e.preventDefault(); this.handleSubmit(); }); } else { // form submission for login this.form.addEventListener('submit', (e) => { e.preventDefault(); this.handleLogin(); }); } } private updateCharCount() { if (!this.messageInput || !this.shadowRoot) { return; } const length = this.messageInput.value.length; const charCountEl = this.shadowRoot.querySelector('#char-count'); if (charCountEl) { charCountEl.textContent = `${length} / 100`; charCountEl.classList.remove('warning', 'error'); if (length > 90) { charCountEl.classList.add('error'); } else if (length > 80) { charCountEl.classList.add('warning'); } } } private async handleLogin() { const handle = this.loginInput?.value.trim(); if (!handle) { this.showStatus('Please enter your Bluesky handle.', 'error'); return; } try { this.showStatus('Redirecting to sign in...', 'loading'); this.setFormDisabled(true); const config = getConfig(); if (!config) { throw new Error('Guestbook not configured. Call configureGuestbook() first.'); } const authUrl = await createAuthorizationUrl({ target: { type: 'account', identifier: handle as `${string}.${string}` }, scope: config.oauth.scope, }); // recommended to wait for the browser to persist local storage before proceeding await sleep(250); // redirect the user to sign in and authorize the app window.location.assign(authUrl); } catch (error) { console.error('Login error:', error); this.showStatus('Login failed. Please try again.', 'error'); this.setFormDisabled(false); } } private async handleSubmit() { if (!globalAgent) { this.showStatus('Please log in first.', 'error'); return; } const did = this.getAttribute('did'); if (!did) { this.showStatus('Missing guestbook DID.', 'error'); return; } // subject is just the DID (at-identifier format) const subject = did; const message = this.messageInput?.value.trim(); if (!message) { this.showStatus('Please enter a message.', 'error'); return; } try { this.showStatus('Signing guestbook...', 'loading'); this.setFormDisabled(true); // get the client from the OAuth agent const client = new Client({ handler: globalAgent }); // create the record const record = { $type: 'pet.nkp.guestbook.sign', subject: subject, message: message, createdAt: new Date().toISOString(), }; const response = await client.post('com.atproto.repo.createRecord', { input: { repo: globalAgent.session.info.sub, // user's DID collection: 'pet.nkp.guestbook.sign', record: record, }, }); if (response.ok) { this.showStatus('✓ Successfully signed the guestbook!', 'success'); // clear the form if (this.messageInput) { this.messageInput.value = ''; } this.updateCharCount(); // dispatch custom event for parent to listen to this.dispatchEvent(new CustomEvent('sign-created', { detail: { uri: response.data.uri, cid: response.data.cid, }, bubbles: true, composed: true, })); // hide success message after 3 seconds setTimeout(() => { this.hideStatus(); }, 3000); } else { throw new Error('Failed to create record'); } } catch (error) { console.error('Error signing guestbook:', error); this.showStatus( `Failed to sign: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error' ); } finally { this.setFormDisabled(false); } } private showStatus(message: string, type: 'success' | 'error' | 'loading') { if (!this.statusDiv) { return; } this.statusDiv.textContent = message; this.statusDiv.className = `status show ${type}`; } private hideStatus() { if (!this.statusDiv) { return; } this.statusDiv.classList.remove('show'); } private setFormDisabled(disabled: boolean) { if (this.messageInput) { this.messageInput.disabled = disabled; } if (this.submitButton) { this.submitButton.disabled = disabled; } if (this.loginInput) { this.loginInput.disabled = disabled; } if (this.loginButton) { this.loginButton.disabled = disabled; } } }