diff options
| -rw-r--r-- | backend/fetchers/app.bsky.feed.go | 20 | ||||
| -rw-r--r-- | backend/notes/note.go | 8 | ||||
| -rw-r--r-- | frontend/src/main.ts | 145 |
3 files changed, 129 insertions, 44 deletions
diff --git a/backend/fetchers/app.bsky.feed.go b/backend/fetchers/app.bsky.feed.go index 5d87cca..4c6b92f 100644 --- a/backend/fetchers/app.bsky.feed.go +++ b/backend/fetchers/app.bsky.feed.go @@ -136,24 +136,18 @@ func parseAppBskyFeedPostCollection(logger *slog.Logger, slingshotURL string, se return notes.DeduplicateThreads(posts), nil } -func FetchParents(logger *slog.Logger, replyToURI string, slingshotURL string, session *auth.OAuthState, cache *Cache, maxDepth int) ([]string, error) { - parents := []string{} - seen := make(map[string]bool) +func FetchParents(logger *slog.Logger, replyToURI string, slingshotURL string, session *auth.OAuthState, cache *Cache, maxDepth int) ([]notes.Note, error) { + parents := []notes.Note{} currentURI := replyToURI for currentURI != "" { - // check for circular reference - if seen[currentURI] { - logger.Warn("circular reference detected in thread", "uri", currentURI) - break - } - seen[currentURI] = true - note, err := GetAppBskyFeedPostByURI(logger, currentURI, slingshotURL, session, cache) if err != nil { logger.Warn("failed to fetch parent", "uri", currentURI, "error", err) - cache.Set(currentURI, notes.MissingNote(currentURI)) - parents = append(parents, currentURI) + note = notes.MissingNote(currentURI) + + cache.Set(currentURI, note) + parents = append(parents, *note) break } @@ -168,7 +162,7 @@ func FetchParents(logger *slog.Logger, replyToURI string, slingshotURL string, s go FetchAppBskyFeedPosts(logger, note.AuthorDID, authorPDS, slingshotURL, session, cache) } - parents = append(parents, currentURI) + parents = append(parents, *note) if note.ReplyTo == nil { break diff --git a/backend/notes/note.go b/backend/notes/note.go index 569188c..36b9f2b 100644 --- a/backend/notes/note.go +++ b/backend/notes/note.go @@ -16,7 +16,7 @@ type Note struct { CreatedAt time.Time `json:"createdAt"` ReplyTo *string `json:"replyTo,omitempty"` Facets []Facet `json:"facets,omitempty"` - Parents []string `json:"parents,omitempty"` + Parents []Note `json:"parents,omitempty"` } type Facet struct { @@ -35,15 +35,15 @@ func MissingNote(uri string) *Note { } } -func isChainContained(smallChain, bigChain []string) bool { +func isChainContained(smallChain, bigChain []Note) bool { if len(smallChain) > len(bigChain) { return false } // check if smallChain is a suffix of bigChain offset := len(bigChain) - len(smallChain) - for i, uri := range smallChain { - if bigChain[offset+i] != uri { + for i, note := range smallChain { + if bigChain[offset+i].URI != note.URI { return false } } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d0cff46..306578d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,25 @@ 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; + }>; +} + // 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 { @@ -66,20 +85,98 @@ function convertFacets(text: string, facets: any[], toMarkown: boolean): string return result; } -function renderAuthor(note: any): string { +function createAuthorElement(note: Note): HTMLElement { + const container = document.createElement('div'); + const hasDisplayName = note.authorName && note.authorName !== ''; const hasValidHandle = note.authorHandle !== 'handle.invalid'; - const pronouns = note.pronouns ? `<span class="muted> · ${note.pronouns}</span>` : ''; if (hasDisplayName && hasValidHandle) { - return `<div><strong>${note.authorName}</strong>${pronouns}</div><div class="muted">@${note.authorHandle}</div>`; + const nameDiv = document.createElement('div'); + nameDiv.innerHTML = `<strong>${note.authorName}</strong>`; + container.appendChild(nameDiv); + + const handleDiv = document.createElement('div'); + handleDiv.className = 'muted'; + handleDiv.textContent = `@${note.authorHandle}`; + container.appendChild(handleDiv); } else if (hasDisplayName && !hasValidHandle) { - return `<div><strong>${note.authorName}</strong>${pronouns}</div><div class="muted">⚠️ InvalidHandle</div>`; + const nameDiv = document.createElement('div'); + nameDiv.innerHTML = `<strong>${note.authorName}</strong>`; + container.appendChild(nameDiv); + + const warningDiv = document.createElement('div'); + warningDiv.className = 'muted'; + warningDiv.textContent = '⚠️ InvalidHandle'; + container.appendChild(warningDiv); } else if (!hasDisplayName && hasValidHandle) { - return `<div><strong>@${note.authorHandle}</strong>${pronouns}</div>`; + const handleDiv = document.createElement('div'); + handleDiv.innerHTML = `<strong>@${note.authorHandle}</strong>`; + container.appendChild(handleDiv); + } else { + const warningDiv = document.createElement('div'); + warningDiv.innerHTML = '<strong>⚠️ InvalidHandle</strong>'; + container.appendChild(warningDiv); + } + + return container; +} + +function createNoteElement(note: Note): HTMLElement { + const noteDiv = document.createElement('div'); + noteDiv.className = 'note'; + + 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 { - return `<div><strong>⚠️ InvalidHandle</strong>${pronouns}</div>`; + textDiv.innerHTML = parsedText; } + content.appendChild(textDiv); + + 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() { @@ -105,32 +202,26 @@ async function main() { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const notes = await response.json(); + const notes: Note[] = await response.json(); + app.innerHTML = ''; - for (const note of notes) { - let parsedText = ''; - if(note.facets && note.facets.length > 0) { - parsedText = convertFacets(note.text || '', note.facets || [], true); + notes.forEach(note => { + if (!note.parents || note.parents.length === 0) { + app.appendChild(createNoteElement(note)); } else { - parsedText = note.text; + // parents were fetched bottom to top, reverse + add child note + const thread: Note[] = [note, ...note.parents].reverse(); + + app.appendChild(createThreadElement(thread)); } - const noteText = marked.parseInline(parsedText, { breaks: true }); - - const postHTML = ` - <div class="note"> - <img class="note-avatar" src="${note.avatarURL}" alt="avatar"> - <div class="note-content"> - ${renderAuthor(note)} - <div class="note-text">${noteText}</div> - <div class="muted">${new Date(note.createdAt).toLocaleString()}</div> - </div> - </div> - `; - app.innerHTML += postHTML; - } + }); } catch (err) { - app.innerHTML = `<div style="color: #f88">error loading posts: ${err}</div>`; + const errorDiv = document.createElement('div'); + errorDiv.style.color = '#f88'; + errorDiv.textContent = `error loading posts: ${err}`; + app.innerHTML = ''; + app.appendChild(errorDiv); } } |
