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