import { marked } from 'marked'; import './style.css' interface Note { uri: string; authorDID: string; authorHandle: string; authorName: string; authorNoUnauth: boolean; avatarURL: string; text: string; createdAt: string; replyTo?: string; parents?: Note[]; facets?: Array<{ byteStart: number; byteEnd: number; type: string; value: string; }>; media?: Array<{ url: string; alt: string; width?: number; height?: number; }>; } // convert Facets to Markdown (before Markdown parsing) if Markdown passing is enabled // convert them to HTML code otherwise function convertFacets(text: string, facets: any[], toMarkown: boolean): string { if (!facets || facets.length === 0) { return text; } const encoder = new TextEncoder(); const postBytes = encoder.encode(text); facets.sort((a, b) => a.byteStart - b.byteStart); let result = ''; let lastPos = 0; const decoder = new TextDecoder(); for (const facet of facets) { const beforeBytes = postBytes.slice(lastPos, facet.byteStart); result += decoder.decode(beforeBytes); const facetBytes = postBytes.slice(facet.byteStart, facet.byteEnd); const facetText = decoder.decode(facetBytes); if (toMarkown) { switch (facet.type) { case 'link': result += `[${facetText}](${facet.value})`; break; case 'mention': result += `[${facetText}](https://bsky.app/profile/${facet.value})`; break; case 'tag': result += `[${facetText}](https://bsky.app/hashtag/${facet.value})`; break; default: // in case Bluesky ever adds other facet types result += facetText; // if we don't recognize the type, just add it as plain text break; } } else { switch (facet.type) { case 'link': result += `${facetText}`; break; case 'mention': result += `${facetText}`; break; case 'tag': result += `${facetText}`; break; default: result += facetText; break; } } lastPos = facet.byteEnd; } // add remaining text const remainingBytes = postBytes.slice(lastPos); result += decoder.decode(remainingBytes); return result; } function createAuthorElement(note: Note): HTMLElement { const container = document.createElement('div'); const hasDisplayName = note.authorName && note.authorName !== ''; const hasValidHandle = note.authorHandle !== 'handle.invalid'; if (hasDisplayName && hasValidHandle) { const nameDiv = document.createElement('div'); nameDiv.innerHTML = `${note.authorName}`; container.appendChild(nameDiv); const handleDiv = document.createElement('div'); handleDiv.className = 'muted'; handleDiv.textContent = `@${note.authorHandle}`; container.appendChild(handleDiv); } else if (hasDisplayName && !hasValidHandle) { const nameDiv = document.createElement('div'); nameDiv.innerHTML = `${note.authorName}`; container.appendChild(nameDiv); const warningDiv = document.createElement('div'); warningDiv.className = 'muted'; warningDiv.textContent = '⚠️ InvalidHandle'; container.appendChild(warningDiv); } else if (!hasDisplayName && hasValidHandle) { const handleDiv = document.createElement('div'); handleDiv.innerHTML = `@${note.authorHandle}`; container.appendChild(handleDiv); } else { const warningDiv = document.createElement('div'); warningDiv.innerHTML = '⚠️ InvalidHandle'; container.appendChild(warningDiv); } return container; } function createNoteElement(note: Note): HTMLElement { const noteDiv = document.createElement('div'); noteDiv.className = 'note'; if (note.authorDID === "MISSING") { noteDiv.classList.add('info'); const textDiv = document.createElement('div'); textDiv.textContent = note.text; noteDiv.appendChild(textDiv); return noteDiv; } const avatar = document.createElement('img'); avatar.className = 'note-avatar'; avatar.src = note.avatarURL || ''; avatar.alt = 'avatar'; noteDiv.appendChild(avatar); const content = document.createElement('div'); content.className = 'note-content'; content.appendChild(createAuthorElement(note)); const textDiv = document.createElement('div'); textDiv.className = 'note-text'; const markdownEnabled = true; // TODO: create a toggleable setting let parsedText = ''; if (note.facets && note.facets.length > 0) { parsedText = convertFacets(note.text || '', note.facets || [], markdownEnabled); } else { parsedText = note.text; } if (markdownEnabled) { textDiv.innerHTML = marked.parseInline(parsedText, { breaks: true }) as string; } else { textDiv.innerHTML = parsedText; } content.appendChild(textDiv); if (note.media && note.media.length > 0) { const mediaDiv = document.createElement('div'); mediaDiv.className = 'note-media'; note.media.forEach(item => { const img = document.createElement('img'); img.src = item.url; img.alt = item.alt || ''; if (item.width && item.height) { img.width = item.width; img.height = item.height; } mediaDiv.appendChild(img); }); content.appendChild(mediaDiv); } const timeDiv = document.createElement('div'); timeDiv.className = 'muted'; timeDiv.textContent = new Date(note.createdAt).toLocaleString(); content.appendChild(timeDiv); noteDiv.appendChild(content); return noteDiv; } function createThreadElement(notes: Note[]): HTMLElement { if (notes.length === 1) { return createNoteElement(notes[0]); } const threadDiv = document.createElement('div'); threadDiv.className = 'reply-chain'; notes.forEach(note => { threadDiv.appendChild(createNoteElement(note)); }); return threadDiv; } async function main() { const sessionCheck = await fetch('/api/session', { credentials: 'include' }); if (!sessionCheck.ok) { window.location.href = '/login.html'; throw new Error('not authenticated'); } const app = document.querySelector('#app'); if (!app) { console.error('App container not found'); throw new Error('App container not found'); } app.innerHTML = '
'; try { const response = await fetch('/api/posts', { credentials: 'include' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const notes: Note[] = await response.json(); app.innerHTML = ''; notes.forEach(note => { if (!note.parents || note.parents.length === 0) { app.appendChild(createNoteElement(note)); } else { // parents were fetched bottom to top, reverse + add child note const thread: Note[] = [note, ...note.parents].reverse(); app.appendChild(createThreadElement(thread)); } }); } catch (err) { const errorDiv = document.createElement('div'); errorDiv.style.color = '#f88'; errorDiv.textContent = `error loading posts: ${err}`; app.innerHTML = ''; app.appendChild(errorDiv); } } main();