a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
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}