a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 231 lines 7.8 kB view raw
1import { apds } from 'apds' 2import { h } from 'h' 3import { send } from './send.js' 4import { getOpenedFromQuery } from './utils.js' 5import { parseOpenedTimestamp } from './feed_row_cache.js' 6 7const editsCache = new Map() 8const EDIT_CACHE_TTL_MS = 5000 9const editState = new Map() 10 11export const getEditState = (hash) => { 12 if (!editState.has(hash)) { 13 editState.set(hash, { currentIndex: null, userNavigated: false }) 14 } 15 return editState.get(hash) 16} 17 18const summarizeBody = (body, maxLen = 50) => { 19 if (!body) { return '' } 20 const single = body.replace(/\n+/g, ' ').trim() 21 if (single.length <= maxLen) { return single } 22 return single.substring(0, maxLen) + '...' 23} 24 25export const fetchEditSnippet = async (editHash) => { 26 if (!editHash) { return '' } 27 const signed = await apds.get(editHash) 28 if (!signed) { return '' } 29 const opened = await getOpenedFromQuery(editHash) 30 if (!opened || opened.length < 14) { return '' } 31 const content = await apds.get(opened.substring(13)) 32 if (!content) { return '' } 33 const yaml = await apds.parseYaml(content) 34 return yaml && yaml.body ? summarizeBody(yaml.body) : '' 35} 36 37export const syncPrevious = (yaml) => { 38 if (!yaml || !yaml.previous) { return } 39 void (async () => { 40 const check = await apds.query(yaml.previous) 41 if (!check[0]) { 42 await send(yaml.previous) 43 } 44 })() 45} 46 47export const updateEditSnippet = (editHash, summaryEl) => { 48 if (!editHash || !summaryEl) { return } 49 void (async () => { 50 const snippet = await fetchEditSnippet(editHash) 51 if (!snippet) { return } 52 const link = summaryEl.querySelector('.edit-summary-link') 53 if (link) { link.textContent = snippet } 54 })() 55} 56 57export const buildEditSummaryLine = ({ name, editHash, author, nameId, snippet }) => { 58 const safeName = name || (author ? author.substring(0, 10) : 'Someone') 59 const safeSnippet = snippet || 'message' 60 const nameEl = author 61 ? h('a', {href: '#' + author, id: nameId, classList: 'avatarlink'}, [safeName]) 62 : h('span', {id: nameId, classList: 'avatarlink'}, [safeName]) 63 return h('span', {classList: 'edit-summary'}, [ 64 nameEl, 65 h('span', {classList: 'edit-summary-verb'}, ['edited']), 66 h('a', {href: '#' + editHash, classList: 'edit-summary-link'}, [safeSnippet]) 67 ]) 68} 69 70export const buildEditSummaryRow = ({ avatarLink, summary }) => { 71 const stack = h('div', {classList: 'message-stack'}, [summary]) 72 return h('div', {classList: 'message-main'}, [ 73 avatarLink || '', 74 stack 75 ]) 76} 77 78export const buildEditMessageShell = ({ id, right, summaryRow, rawDiv, qrTarget }) => { 79 return h('div', {id, classList: 'message edit-message'}, [ 80 right, 81 summaryRow, 82 rawDiv, 83 qrTarget 84 ]) 85} 86 87export const extractMetaNodes = (msgDiv) => { 88 const right = msgDiv.querySelector('.message-meta') 89 const rawDiv = msgDiv.querySelector('.message-raw') || h('div', {classList: 'message-raw'}) 90 const qrTarget = msgDiv.querySelector('.qr-target') 91 return { right, rawDiv, qrTarget } 92} 93 94export const fetchEditsForMessage = async (hash, author) => { 95 if (!author) { return [] } 96 const cached = editsCache.get(hash) 97 const now = Date.now() 98 if (cached && now - cached.at < EDIT_CACHE_TTL_MS) { 99 return cached.edits 100 } 101 const log = await apds.query(author) 102 if (!log) { return [] } 103 const edits = [] 104 for (const msg of log) { 105 let text = msg.text 106 if (!text && msg.opened) { 107 text = await apds.get(msg.opened.substring(13)) 108 } 109 if (!text) { continue } 110 const yaml = await apds.parseYaml(text) 111 if (yaml && yaml.edit === hash) { 112 const ts = msg.ts || parseOpenedTimestamp(msg.opened) 113 edits.push({ hash: msg.hash, author: msg.author || author, ts, yaml }) 114 } 115 } 116 edits.sort((a, b) => a.ts - b.ts) 117 editsCache.set(hash, { at: now, edits }) 118 return edits 119} 120 121export const invalidateEdits = (hash) => { 122 editsCache.delete(hash) 123} 124 125export const registerMessage = (hash, data) => { 126 const state = getEditState(hash) 127 Object.assign(state, data) 128} 129 130export const buildEditNav = (hash, stepEdit) => { 131 const left = h('a', { 132 classList: 'material-symbols-outlined edit-nav-btn', 133 onclick: async (e) => { 134 e.preventDefault() 135 await stepEdit(hash, -1) 136 } 137 }, ['Chevron_Left']) 138 139 const index = h('span', {classList: 'edit-nav-index'}, ['']) 140 141 const right = h('a', { 142 classList: 'material-symbols-outlined edit-nav-btn', 143 onclick: async (e) => { 144 e.preventDefault() 145 await stepEdit(hash, 1) 146 } 147 }, ['Chevron_Right']) 148 149 const wrap = h('span', {classList: 'edit-nav', style: 'display: none;'}, [ 150 left, 151 index, 152 right 153 ]) 154 155 return { wrap, left, right, index } 156} 157 158// refreshEdits and stepEdit need renderBody, highlightCodeIn, hydrateReplyPreviews, applyProfile 159// from render.js. We accept them as a callbacks object to avoid circular deps. 160export const createEditActions = ({ renderBody, highlightCodeIn, hydrateReplyPreviews, applyProfile }) => { 161 const refreshEdits = async (hash, options = {}) => { 162 const state = getEditState(hash) 163 if (!state.baseYaml || !state.contentDiv) { return } 164 const edits = (await fetchEditsForMessage(hash, state.author)) 165 .filter(edit => !state.author || edit.author === state.author) 166 if (!edits.length) { 167 if (state.editNav) { state.editNav.wrap.style.display = 'none' } 168 if (state.editedHint) { state.editedHint.style.display = 'none' } 169 return 170 } 171 172 const total = edits.length + 1 173 const latestIndex = total - 1 174 if (options.forceLatest || (!state.userNavigated && state.currentIndex === null)) { 175 state.currentIndex = latestIndex 176 } 177 if (state.currentIndex === null) { state.currentIndex = latestIndex } 178 state.currentIndex = Math.max(0, Math.min(state.currentIndex, latestIndex)) 179 180 if (state.editNav) { 181 state.editNav.wrap.style.display = 'inline' 182 state.editNav.index.textContent = (state.currentIndex + 1) + '/' + total 183 state.editNav.left.classList.toggle('disabled', state.currentIndex === 0) 184 state.editNav.right.classList.toggle('disabled', state.currentIndex === latestIndex) 185 } 186 187 const currentEdit = state.currentIndex > 0 ? edits[state.currentIndex - 1] : null 188 const hintEdit = currentEdit || edits[edits.length - 1] 189 if (state.editedHint) { 190 const hintTs = hintEdit.ts ? hintEdit.ts.toString() : state.baseTimestamp 191 state.editedHint.textContent = hintTs ? 'edit: ' + await apds.human(hintTs) : 'edit' 192 state.editedHint.style.display = 'inline' 193 } 194 195 const baseReply = state.baseYaml.reply || state.baseYaml.replyHash 196 const bodySource = currentEdit && currentEdit.yaml && currentEdit.yaml.body 197 ? currentEdit.yaml.body 198 : state.baseYaml.body 199 state.currentBody = bodySource 200 state.contentDiv.innerHTML = await renderBody(bodySource, baseReply) 201 await highlightCodeIn(state.contentDiv) 202 hydrateReplyPreviews(state.contentDiv) 203 if (!currentEdit) { 204 await applyProfile(state.contentHash, state.baseYaml) 205 } 206 } 207 208 const stepEdit = async (hash, delta) => { 209 const state = getEditState(hash) 210 if (!state.baseYaml) { return } 211 const edits = (await fetchEditsForMessage(hash, state.author)) 212 .filter(edit => !state.author || edit.author === state.author) 213 const total = edits.length + 1 214 if (total <= 1) { return } 215 const nextIndex = Math.max(0, Math.min((state.currentIndex ?? total - 1) + delta, total - 1)) 216 state.currentIndex = nextIndex 217 state.userNavigated = true 218 await refreshEdits(hash) 219 } 220 221 return { refreshEdits, stepEdit } 222} 223 224export const queueEditRefresh = (editHash, ensureOriginalMessage, invalidate, refresh) => { 225 if (!editHash) { return } 226 void (async () => { 227 await ensureOriginalMessage(editHash) 228 invalidate(editHash) 229 await refresh(editHash, { forceLatest: true }) 230 })() 231}