a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 230 lines 7.6 kB view raw
1import { apds } from 'apds' 2import { h } from 'h' 3import { queueSend } from './network_queue.js' 4import { composer } from './composer.js' 5import { promptKeypair } from './identify.js' 6import { ensureReplyIndex, getReplyCount, getRepliesForParent } from './reply_index.js' 7import { getOpenedFromQuery } from './utils.js' 8 9const replyCountTargets = new Map() 10let replyObserver = null 11const replyPreviewCache = new Map() 12 13// Late-bound reference to the render object, set via initReplyRenderer() 14let render = null 15 16export const initReplyRenderer = (renderObj) => { 17 render = renderObj 18} 19 20const summarizeBody = (body, maxLen = 50) => { 21 if (!body) { return '' } 22 const single = body.replace(/\s+/g, ' ').trim() 23 if (single.length <= maxLen) { return single } 24 return single.substring(0, maxLen) + '...' 25} 26 27export const updateReplyCount = (parentHash) => { 28 const target = replyCountTargets.get(parentHash) 29 if (!target) { return } 30 const count = getReplyCount(parentHash) 31 target.textContent = count ? count.toString() : '' 32} 33 34export const observeReplies = (wrapper, parentHash) => { 35 if (!wrapper) { return } 36 if (!replyObserver) { 37 replyObserver = new IntersectionObserver((entries) => { 38 entries.forEach(entry => { 39 const target = entry.target 40 if (!entry.isIntersecting) { return } 41 const hash = target.dataset.replyParent 42 if (!hash) { return } 43 if (target.dataset.repliesLoaded === 'true') { return } 44 const list = getRepliesForParent(hash) 45 if (!list.length) { 46 target.dataset.repliesLoaded = 'true' 47 return 48 } 49 void (async () => { 50 for (const item of list) { 51 await appendReply(hash, item.hash, item.ts, null, item.opened) 52 } 53 target.dataset.repliesLoaded = 'true' 54 })() 55 }) 56 }) 57 } 58 wrapper.dataset.replyParent = parentHash 59 replyObserver.observe(wrapper) 60} 61 62export const buildReplyIndex = async (log = null) => { 63 replyCountTargets.clear() 64 await ensureReplyIndex(log) 65} 66 67export const refreshVisibleReplies = () => { 68 replyCountTargets.forEach((_, parentHash) => { 69 updateReplyCount(parentHash) 70 }) 71 const wrappers = Array.from(document.querySelectorAll('.message-wrapper')) 72 wrappers.forEach((wrapper) => { 73 const parentHash = wrapper.id 74 if (!parentHash) { return } 75 if (wrapper.dataset.repliesLoaded === 'true') { return } 76 if (!getReplyCount(parentHash)) { return } 77 observeReplies(wrapper, parentHash) 78 }) 79} 80 81export const getReplyParent = (yaml) => { 82 if (!yaml) { return null } 83 return yaml.replyHash || yaml.reply || null 84} 85 86export const appendReply = async (parentHash, replyHash, ts, replyBlob = null, replyOpened = null) => { 87 const wrapper = document.getElementById(parentHash) 88 const repliesContainer = wrapper ? wrapper.querySelector('.message-replies') : null 89 if (!repliesContainer) { return false } 90 const blob = replyBlob || await apds.get(replyHash) 91 if (!blob) { return false } 92 let replyWrapper = document.getElementById(replyHash) 93 if (!replyWrapper) { 94 replyWrapper = render.hash(replyHash) 95 } 96 if (!replyWrapper) { return true } 97 if (replyOpened) { 98 replyWrapper.dataset.opened = replyOpened 99 } 100 const scroller = document.getElementById('scroller') 101 if (scroller && scroller.contains(replyWrapper)) { 102 await render.blob(blob, { hash: replyHash, opened: replyOpened }) 103 return true 104 } 105 const replyParent = replyWrapper.parentNode 106 const alreadyNested = replyParent && replyParent.classList && replyParent.classList.contains('reply') 107 if (!alreadyNested || replyParent.parentNode !== repliesContainer) { 108 const replyContain = h('div', {classList: 'reply'}) 109 if (ts) { replyContain.dataset.ts = ts.toString() } 110 replyContain.appendChild(replyWrapper) 111 repliesContainer.appendChild(replyContain) 112 } 113 await render.blob(blob, { hash: replyHash, opened: replyOpened }) 114 return true 115} 116 117export const flushPendingReplies = async (parentHash) => { 118 const wrapper = document.getElementById(parentHash) 119 if (!wrapper) { return } 120 const list = getRepliesForParent(parentHash) 121 if (!list.length) { return } 122 observeReplies(wrapper, parentHash) 123} 124 125export const fetchReplyPreview = async (replyHash) => { 126 if (!replyHash) { return null } 127 if (replyPreviewCache.has(replyHash)) { return replyPreviewCache.get(replyHash) } 128 const signed = await apds.get(replyHash) 129 if (!signed) { 130 queueSend(replyHash, { priority: 'low' }) 131 replyPreviewCache.set(replyHash, null) 132 return null 133 } 134 const opened = await getOpenedFromQuery(replyHash) 135 if (!opened || opened.length < 14) { 136 replyPreviewCache.set(replyHash, null) 137 return null 138 } 139 const contentHash = opened.substring(13) 140 const content = await apds.get(contentHash) 141 if (!content) { 142 queueSend(contentHash, { priority: 'low' }) 143 replyPreviewCache.set(replyHash, null) 144 return null 145 } 146 const yaml = await apds.parseYaml(content) 147 const author = signed.substring(0, 44) 148 const name = yaml && yaml.name ? yaml.name.trim() : author.substring(0, 10) 149 const body = yaml && yaml.body ? summarizeBody(yaml.body, 20) : '' 150 let avatarSrc = null 151 try { 152 const img = await apds.visual(author) 153 avatarSrc = img && img.src ? img.src : null 154 } catch { 155 avatarSrc = null 156 } 157 const preview = { author, name, body, avatarSrc } 158 replyPreviewCache.set(replyHash, preview) 159 return preview 160} 161 162export const hydrateReplyPreviews = (container) => { 163 if (!container) { return } 164 const targets = container.querySelectorAll('[data-reply-preview]') 165 targets.forEach(target => { 166 if (target.dataset.replyPreviewHydrated === 'true') { return } 167 target.dataset.replyPreviewHydrated = 'true' 168 const replyHash = target.dataset.replyPreview 169 if (!replyHash) { return } 170 void (async () => { 171 const preview = await fetchReplyPreview(replyHash) 172 if (!preview) { return } 173 while (target.firstChild) { target.firstChild.remove() } 174 if (preview.name && preview.author) { 175 target.appendChild(h('a', { 176 href: '#' + preview.author, 177 classList: 'reply-preview-author' 178 }, [preview.name])) 179 } 180 target.appendChild(h('span', { 181 classList: 'material-symbols-outlined reply-preview-icon' 182 }, ['Subdirectory_Arrow_left'])) 183 const linkText = preview.body || (replyHash.substring(0, 10) + '...') 184 const link = h('a', { 185 href: '#' + replyHash, 186 classList: 'reply-preview-link', 187 title: preview.name || '' 188 }, [linkText]) 189 target.appendChild(link) 190 })() 191 }) 192} 193 194export const comments = async (hash, blob, div, actionsRow) => { 195 const num = h('span') 196 replyCountTargets.set(hash, num) 197 updateReplyCount(hash) 198 const list = getRepliesForParent(hash) 199 if (list.length) { 200 const wrapper = document.getElementById(hash) 201 observeReplies(wrapper, hash) 202 } 203 204 const reply = h('a', { 205 classList: 'material-symbols-outlined', 206 onclick: async () => { 207 if (document.getElementById('reply-composer-' + hash)) { return } 208 if (await apds.pubkey()) { 209 div.after(await composer(blob)) 210 return 211 } 212 promptKeypair() 213 } 214 }, ['Chat_Bubble']) 215 216 if (actionsRow) { 217 const slot = actionsRow.querySelector('.message-actions-reply') 218 if (slot) { 219 slot.appendChild(reply) 220 slot.appendChild(h('span', [' '])) 221 slot.appendChild(num) 222 } 223 } else { 224 const target = h('div', {style: 'margin-left: 43px;'}) 225 target.appendChild(reply) 226 target.appendChild(h('span', [' '])) 227 target.appendChild(num) 228 div.appendChild(target) 229 } 230}