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;
}
}
}