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 { queueSend } from './network_queue.js'
5import { composer } from './composer.js'
6import { markdown } from './markdown.js'
7import { noteSeen } from './sync.js'
8import { isBlockedAuthor, shouldHideMessage } from './moderation.js'
9import { ensureHighlight } from './lazy_vendor.js'
10import { addReplyToIndex, getReplyCount } from './reply_index.js'
11import { makeFeedRow, upsertFeedRow, parseOpenedTimestamp } from './feed_row_cache.js'
12import { perfStart, perfEnd } from './perf.js'
13import { isHash, getOpenedFromQuery } from './utils.js'
14import {
15 getEditState, syncPrevious, updateEditSnippet,
16 buildEditSummaryLine, buildEditSummaryRow, buildEditMessageShell,
17 extractMetaNodes, invalidateEdits,
18 registerMessage, buildEditNav, createEditActions,
19 queueEditRefresh as _queueEditRefresh
20} from './edit_renderer.js'
21import { observeTimestamp } from './timestamp_observer.js'
22import { insertByTimestamp } from './timestamp_insert.js'
23import { buildQR } from './qr_widget.js'
24import {
25 initReplyRenderer, updateReplyCount, observeReplies,
26 buildReplyIndex, refreshVisibleReplies,
27 getReplyParent, appendReply, flushPendingReplies,
28 hydrateReplyPreviews, comments
29} from './reply_renderer.js'
30import {
31 initModerationUI, applyModerationStub, buildModerationControls
32} from './moderation_ui.js'
33
34export const render = {}
35const cache = new Map()
36let cachedPubkeyPromise = null
37
38// Wire up modules that need late-bound render reference
39initReplyRenderer(render)
40initModerationUI(render)
41
42const getCachedPubkey = async () => {
43 if (!cachedPubkeyPromise) {
44 cachedPubkeyPromise = apds.pubkey().catch((err) => {
45 cachedPubkeyPromise = null
46 throw err
47 })
48 }
49 return cachedPubkeyPromise
50}
51
52const highlightCodeIn = async (container) => {
53 if (!container) { return }
54 const nodes = Array.from(container.querySelectorAll('pre code, pre'))
55 if (!nodes.length) { return }
56 let hljs
57 try {
58 hljs = await ensureHighlight()
59 } catch (err) {
60 console.warn('highlight load failed', err)
61 return
62 }
63 if (!hljs || typeof hljs.highlightElement !== 'function') { return }
64 nodes.forEach((node) => {
65 const target = node.matches('pre') && node.querySelector('code')
66 ? node.querySelector('code')
67 : node
68 if (!target || target.dataset.hljsDone === 'true') { return }
69 hljs.highlightElement(target)
70 target.dataset.hljsDone = 'true'
71 })
72}
73
74render.buildReplyIndex = buildReplyIndex
75render.refreshVisibleReplies = refreshVisibleReplies
76
77const renderBody = async (body, replyHash) => {
78 let html = body ? await markdown(body) : ''
79 if (replyHash) {
80 const preview = "<span class='reply-preview' data-reply-preview='" + replyHash + "'>" +
81 "<span class='material-symbols-outlined reply-preview-icon'>Subdirectory_Arrow_left</span>" +
82 "<a href='#" + replyHash + "' class='reply-preview-link'>" +
83 replyHash.substring(0, 10) + "...</a></span>"
84 html = preview + html
85 }
86 return html
87}
88
89const buildRawControls = (blob, opened, contentBlob) => {
90 const rawDiv = h('div', {classList: 'message-raw'})
91 let rawshow = true
92 let rawContent
93
94 const raw = h('a', {classList: 'material-symbols-outlined', onclick: async () => {
95 if (rawshow) {
96 if (!rawContent) {
97 rawContent = h('pre', {classList: 'hljs'}, [blob + '\n\n' + opened + '\n\n' + (contentBlob || '')])
98 }
99 rawDiv.appendChild(rawContent)
100 rawshow = false
101 } else {
102 rawContent.parentNode.removeChild(rawContent)
103 rawshow = true
104 }
105 }}, ['Code'])
106
107 return { raw, rawDiv }
108}
109
110const _insertByTimestamp = (container, hash, ts) => insertByTimestamp(container, hash, ts, render.hash)
111
112const ensureOriginalMessage = async (targetHash) => {
113 if (!targetHash) { return }
114 const existing = document.getElementById(targetHash)
115 const scroller = document.getElementById('scroller')
116 if (!existing && scroller) {
117 const signed = await apds.get(targetHash)
118 if (signed) {
119 const opened = await getOpenedFromQuery(targetHash)
120 const ts = parseOpenedTimestamp(opened)
121 _insertByTimestamp(scroller, targetHash, ts)
122 }
123 }
124 const have = await apds.get(targetHash)
125 if (!have) {
126 await send(targetHash)
127 }
128}
129
130const queueEditRefresh = (editHash) => _queueEditRefresh(editHash, ensureOriginalMessage, render.invalidateEdits, render.refreshEdits)
131
132const buildRightMeta = ({ author, hash, blob, qrTarget, raw, ts }) => {
133 const permalink = h('a', {href: '#' + blob, classList: 'material-symbols-outlined'}, ['Share'])
134 return h('span', {classList: 'message-meta'}, [
135 h('span', {classList: 'pubkey'}, [author.substring(0, 6)]),
136 ' ',
137 render.qr(hash, blob, qrTarget),
138 ' ',
139 permalink,
140 ' ',
141 raw,
142 ' ',
143 ts,
144 ])
145}
146
147const applyProfile = async (contentHash, yaml) => {
148 if (yaml.image) {
149 const get = document.getElementById('image' + contentHash)
150 if (get) {
151 if (cache.get(yaml.image)) {
152 get.src = cache.get(yaml.image)
153 } else {
154 const image = await apds.get(yaml.image)
155 cache.set(yaml.image, image)
156 if (image) {
157 get.src = image
158 } else { send(yaml.image) }
159 }
160 }
161 }
162
163 if (yaml.name) {
164 const get = document.getElementById('name' + contentHash)
165 if (get) { get.textContent = yaml.name }
166 }
167}
168
169const queueLinkedHashes = async (yaml) => {
170 if (!yaml) { return }
171 const candidates = new Set()
172 if (isHash(yaml.replyHash)) { candidates.add(yaml.replyHash) }
173 if (isHash(yaml.reply)) { candidates.add(yaml.reply) }
174 if (isHash(yaml.previous)) {
175 candidates.add(yaml.previous)
176 }
177 if (isHash(yaml.edit)) { candidates.add(yaml.edit) }
178 if (isHash(yaml.image)) { candidates.add(yaml.image) }
179 const replyAuthor = isHash(yaml.replyto) ? yaml.replyto : (isHash(yaml.replyTo) ? yaml.replyTo : null)
180 for (const hash of candidates) {
181 if (hash === yaml.image) {
182 const have = await apds.get(hash)
183 if (!have) { queueSend(hash, { priority: 'low' }) }
184 continue
185 }
186 const query = await apds.query(hash)
187 if (!query || !query[0]) { queueSend(hash, { priority: 'low' }) }
188 }
189 if (replyAuthor) {
190 const query = await apds.query(replyAuthor)
191 if (!query || !query[0]) { queueSend(replyAuthor, { priority: 'low' }) }
192 }
193}
194
195const buildPreviewNode = (row) => {
196 const author = row?.author || ''
197 const name = row?.name || (author ? author.substring(0, 10) : 'unknown')
198 const preview = row?.preview || 'Loading message...'
199 const replyCount = Number.isFinite(row?.replyCount) ? row.replyCount : 0
200 const authorHref = author ? ('#' + author) : '#'
201 return h('div', {classList: 'message message-preview'}, [
202 h('div', {classList: 'message-main'}, [
203 h('span', {classList: 'avatarlink'}, [name]),
204 h('div', {classList: 'message-stack'}, [
205 h('a', {href: authorHref, classList: 'avatarlink'}, [name]),
206 h('div', {classList: 'message-body'}, [preview]),
207 replyCount > 0
208 ? h('div', {classList: 'message-meta'}, [`${replyCount} repl${replyCount === 1 ? 'y' : 'ies'}`])
209 : ''
210 ])
211 ])
212 ])
213}
214
215render.applyRowPreview = (wrapper, row) => {
216 if (!wrapper || !row) { return false }
217 const shell = wrapper.classList && wrapper.classList.contains('message-wrapper')
218 ? wrapper.querySelector('.message-shell')
219 : wrapper
220 if (!shell || !shell.classList || !shell.classList.contains('premessage')) { return false }
221 if (shell.dataset.previewReady === 'true') { return false }
222 while (shell.firstChild) {
223 shell.firstChild.remove()
224 }
225 shell.appendChild(buildPreviewNode(row))
226 shell.dataset.previewReady = 'true'
227 if (row.ts && !wrapper.dataset.ts) {
228 wrapper.dataset.ts = String(row.ts)
229 }
230 if (row.opened && !wrapper.dataset.opened) {
231 wrapper.dataset.opened = row.opened
232 }
233 if (row.author && !wrapper.dataset.author) {
234 wrapper.dataset.author = row.author
235 }
236 return true
237}
238
239render.registerMessage = (hash, data) => registerMessage(hash, data)
240render.invalidateEdits = (hash) => invalidateEdits(hash)
241
242// Wire up edit actions with render-local dependencies
243const { refreshEdits, stepEdit } = createEditActions({
244 renderBody, highlightCodeIn, hydrateReplyPreviews, applyProfile
245})
246render.refreshEdits = refreshEdits
247render.stepEdit = stepEdit
248
249render.qr = (hash, blob, target) => buildQR(hash, blob, target)
250
251const renderEditMeta = async ({ blob, opened, hash, div, timestamp, contentHash, author, humanTime, img, contentBlob, yaml }) => {
252 queueEditRefresh(yaml.edit)
253 syncPrevious(yaml)
254
255 const ts = h('a', {href: '#' + hash}, [humanTime])
256 observeTimestamp(ts, timestamp)
257
258 const qrTarget = h('div', {id: 'qr-target' + hash, classList: 'qr-target', style: 'margin: 8px auto 0 auto; text-align: center; width: min(90vw, 400px); max-width: 400px;'})
259 const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob)
260 const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts })
261
262 img.className = 'avatar'
263 img.id = 'image' + contentHash
264 img.style = 'float: left;'
265
266 const summary = buildEditSummaryLine({
267 name: yaml.name,
268 editHash: yaml.edit,
269 author,
270 nameId: 'name' + contentHash,
271 })
272 updateEditSnippet(yaml.edit, summary)
273 const summaryRow = buildEditSummaryRow({
274 avatarLink: h('a', {href: '#' + author}, [img]),
275 summary
276 })
277 const meta = buildEditMessageShell({
278 id: div.id,
279 right,
280 summaryRow,
281 rawDiv,
282 qrTarget
283 })
284 meta.dataset.author = author
285 if (div.dataset.ts) {
286 meta.dataset.ts = div.dataset.ts
287 }
288
289 div.replaceWith(meta)
290 await applyProfile(contentHash, yaml)
291}
292
293const buildActionRow = ({ author, hash, blob, opened, editButton, editedHint, editNav }) => {
294 const replySlot = h('span', {classList: 'message-actions-reply'})
295 const moderationControls = buildModerationControls({ author, hash, blob, opened })
296 const editControls = h('span', {classList: 'message-actions-edit'}, [
297 editButton || '',
298 editButton ? ' ' : '',
299 editedHint,
300 ' ',
301 editNav.wrap
302 ])
303 editControls.appendChild(moderationControls)
304 return h('div', {classList: 'message-actions'}, [
305 replySlot,
306 editControls
307 ])
308}
309
310const buildMessageDOM = async ({ blob, opened, hash, div, timestamp, contentHash, author, humanTime, img, contentBlob, yaml }) => {
311 const ts = h('a', {href: '#' + hash}, [humanTime])
312 observeTimestamp(ts, timestamp)
313
314 const pubkey = await getCachedPubkey()
315 const canEdit = pubkey && pubkey === author
316 const editButton = canEdit ? h('a', {
317 classList: 'material-symbols-outlined',
318 onclick: async (e) => {
319 e.preventDefault()
320 const state = getEditState(hash)
321 const body = state.currentBody || state.baseYaml?.body || ''
322 const overlay = await composer(null, { editHash: hash, editBody: body })
323 document.body.appendChild(overlay)
324 }
325 }, ['Edit']) : null
326
327 const { raw, rawDiv } = buildRawControls(blob, opened, contentBlob)
328
329 const qrTarget = h('div', {id: 'qr-target' + hash, classList: 'qr-target', style: 'margin: 8px auto 0 auto; text-align: center; width: min(90vw, 400px); max-width: 400px;'})
330 const editedHint = h('span', {classList: 'edit-hint', style: 'display: none;'}, [''])
331 const editNav = buildEditNav(hash, render.stepEdit)
332 const right = buildRightMeta({ author, hash, blob, qrTarget, raw, ts })
333
334 img.className = 'avatar'
335 img.id = 'image' + contentHash
336 img.style = 'float: left;'
337
338 const name = h('span', {id: 'name' + contentHash, classList: 'avatarlink'}, [author.substring(0, 10)])
339
340 const content = h('div', {
341 id: contentHash,
342 classList: 'material-symbols-outlined content',
343 onclick: async () => {
344 const blob = await apds.get(contentHash)
345 if (blob) {
346 send(blob)
347 } else {
348 send(contentHash)
349 }
350 }
351 }, ['Notes'])
352
353 const actionsRow = buildActionRow({ author, hash, blob, opened, editButton, editedHint, editNav })
354
355 const meta = h('div', {classList: 'message'}, [
356 right,
357 h('div', {classList: 'message-main'}, [
358 h('a', {href: '#' + author}, [img]),
359 h('div', {classList: 'message-stack'}, [
360 h('a', {href: '#' + author}, [name]),
361 h('div', {classList: 'message-body'}, [
362 h('div', {id: 'reply' + contentHash}),
363 content,
364 rawDiv,
365 actionsRow
366 ])
367 ])
368 ]),
369 qrTarget
370 ])
371
372 div.replaceWith(meta)
373 render.registerMessage(hash, {
374 author,
375 baseTimestamp: timestamp,
376 contentHash,
377 contentDiv: content,
378 editedHint,
379 editNav
380 })
381 const commentsPromise = render.comments(hash, blob, meta, actionsRow)
382 await Promise.all([
383 commentsPromise,
384 contentBlob ? render.content(contentHash, contentBlob, content, hash, yaml) : send(contentHash)
385 ])
386}
387
388render.meta = async (blob, opened, hash, div, options = {}) => {
389 const timestamp = opened.substring(0, 13)
390 const contentHash = opened.substring(13)
391 const author = blob.substring(0, 44)
392 const wrapper = document.getElementById(hash)
393 if (wrapper) {
394 wrapper.dataset.ts = timestamp
395 wrapper.dataset.author = author
396 }
397
398 const contentPromise = options.contentBlob !== undefined
399 ? Promise.resolve(options.contentBlob)
400 : apds.get(contentHash)
401 const [humanTime, fallbackContentBlob, img] = await Promise.all([
402 apds.human(timestamp),
403 contentPromise,
404 apds.visual(author)
405 ])
406
407 const contentBlob = options.contentBlob || fallbackContentBlob
408 let yaml = options.yaml || null
409 if (!yaml && contentBlob) {
410 yaml = await apds.parseYaml(contentBlob)
411 }
412 const row = makeFeedRow({
413 hash,
414 opened,
415 author,
416 contentHash,
417 yaml,
418 ts: parseOpenedTimestamp(opened)
419 })
420 if (row) {
421 row.replyCount = getReplyCount(hash)
422 upsertFeedRow(row)
423 }
424
425 if (!options.forceShow) {
426 const moderation = await shouldHideMessage({
427 author,
428 hash,
429 body: yaml?.body || ''
430 })
431 if (moderation.hidden) {
432 if (moderation.code === 'blocked-author') {
433 const wrapper = document.getElementById(hash)
434 if (wrapper) { wrapper.remove() }
435 return
436 }
437 await applyModerationStub({
438 target: div,
439 hash,
440 author,
441 moderation,
442 blob,
443 opened
444 })
445 return
446 }
447 }
448
449 const ctx = { blob, opened, hash, div, timestamp, contentHash, author, humanTime, img, contentBlob, yaml }
450
451 if (yaml && yaml.edit) {
452 return renderEditMeta(ctx)
453 }
454
455 return buildMessageDOM(ctx)
456}
457
458render.comments = comments
459
460const contentEditBranch = async (contentHash, yaml, div, messageHash) => {
461 queueEditRefresh(yaml.edit)
462 syncPrevious(yaml)
463 const msgDiv = messageHash ? document.getElementById(messageHash) : null
464 if (msgDiv && div && div.parentNode) {
465 const state = getEditState(messageHash)
466 const author = state && state.author ? state.author : null
467 const summary = buildEditSummaryLine({
468 name: yaml.name,
469 editHash: yaml.edit,
470 author,
471 nameId: 'name' + contentHash,
472 })
473 updateEditSnippet(yaml.edit, summary)
474 const avatarImg = msgDiv.querySelector('img.avatar')
475 const avatarLink = avatarImg ? avatarImg.parentNode : null
476 if (avatarLink && avatarImg) {
477 while (avatarLink.firstChild) { avatarLink.removeChild(avatarLink.firstChild) }
478 avatarLink.appendChild(avatarImg)
479 }
480
481 const summaryRow = buildEditSummaryRow({ avatarLink, summary })
482 const { right, rawDiv, qrTarget } = extractMetaNodes(msgDiv)
483 msgDiv.classList.add('edit-message')
484 while (msgDiv.firstChild) { msgDiv.removeChild(msgDiv.firstChild) }
485 if (right) { msgDiv.appendChild(right) }
486 msgDiv.appendChild(summaryRow)
487 msgDiv.appendChild(rawDiv)
488 if (qrTarget) { msgDiv.appendChild(qrTarget) }
489
490 await applyProfile(contentHash, yaml)
491 await queueLinkedHashes(yaml)
492 return
493 }
494 div.className = 'content'
495 while (div.firstChild) { div.firstChild.remove() }
496 const summaryRow = buildEditSummaryRow({
497 summary: buildEditSummaryLine({ name: yaml.name, editHash: yaml.edit })
498 })
499 updateEditSnippet(yaml.edit, summaryRow)
500 div.appendChild(summaryRow)
501 await queueLinkedHashes(yaml)
502}
503
504const contentBioBranch = async (contentHash, yaml, div) => {
505 div.classList.remove('material-symbols-outlined')
506 const bioHtml = await markdown(yaml.bio)
507 div.innerHTML = `<p><strong>New bio:</strong></p>${bioHtml}`
508 await highlightCodeIn(div)
509 await applyProfile(contentHash, yaml)
510 await queueLinkedHashes(yaml)
511}
512
513const contentBodyBranch = async (contentHash, yaml, div, messageHash) => {
514 div.className = 'content'
515 if (yaml.replyHash) { yaml.reply = yaml.replyHash }
516 if (messageHash && yaml.reply) {
517 const messageWrapper = document.getElementById(messageHash)
518 const messageOpened = messageWrapper?.dataset?.opened || null
519 const messageTs = messageOpened ? parseOpenedTimestamp(messageOpened) : 0
520 addReplyToIndex(yaml.reply, messageHash, messageTs, messageOpened)
521 updateReplyCount(yaml.reply)
522 }
523 div.innerHTML = await renderBody(yaml.body, yaml.reply)
524 await highlightCodeIn(div)
525 hydrateReplyPreviews(div)
526 await applyProfile(contentHash, yaml)
527 await queueLinkedHashes(yaml)
528
529 if (messageHash) {
530 render.registerMessage(messageHash, {
531 baseYaml: yaml,
532 contentHash,
533 contentDiv: div,
534 currentBody: yaml.body
535 })
536 await render.refreshEdits(messageHash)
537 }
538}
539
540render.content = async (hash, blob, div, messageHash, preParsedYaml = null) => {
541 const contentHashPromise = hash ? Promise.resolve(hash) : apds.hash(blob)
542 const [contentHash, yaml] = await Promise.all([
543 contentHashPromise,
544 preParsedYaml ? Promise.resolve(preParsedYaml) : apds.parseYaml(blob)
545 ])
546
547 if (yaml && yaml.edit) {
548 return contentEditBranch(contentHash, yaml, div, messageHash)
549 }
550 if (yaml && yaml.bio && (!yaml.body || !yaml.body.trim())) {
551 return contentBioBranch(contentHash, yaml, div)
552 }
553 if (yaml && yaml.body) {
554 return contentBodyBranch(contentHash, yaml, div, messageHash)
555 }
556}
557
558render.blob = async (blob, meta = {}) => {
559 const token = perfStart('render.blob', meta.hash || 'unknown')
560 const forceShow = Boolean(meta.forceShow)
561 let hash = meta.hash || null
562 let wrapper = hash ? document.getElementById(hash) : null
563 if (!hash && wrapper) { hash = wrapper.id }
564 if (!hash) { hash = await apds.hash(blob) }
565 if (!wrapper && hash) { wrapper = document.getElementById(hash) }
566
567 let opened = meta.opened || (wrapper && wrapper.dataset ? wrapper.dataset.opened : null)
568 if (!opened && hash) {
569 opened = await getOpenedFromQuery(hash)
570 }
571 if (opened && wrapper && wrapper.dataset && !wrapper.dataset.opened) {
572 wrapper.dataset.opened = opened
573 }
574
575 const div = wrapper && wrapper.classList.contains('message-wrapper')
576 ? wrapper.querySelector('.message-shell')
577 : wrapper
578 let contentBlob = null
579 let parsedYaml = null
580 if (opened) {
581 contentBlob = await apds.get(opened.substring(13))
582 if (contentBlob) {
583 parsedYaml = await apds.parseYaml(contentBlob)
584 if (parsedYaml && parsedYaml.edit) {
585 queueEditRefresh(parsedYaml.edit)
586 }
587 }
588 }
589
590 const getimg = document.getElementById('inlineimage' + hash)
591 if (opened && div && !div.childNodes[1]) {
592 await render.meta(blob, opened, hash, div, { forceShow, contentBlob, yaml: parsedYaml })
593 } else if (div && !div.childNodes[1]) {
594 if (div.className.includes('content')) {
595 await render.content(hash, blob, div, null, parsedYaml)
596 } else {
597 const content = h('div', {classList: 'content'})
598 const message = h('div', {classList: 'message'}, [content])
599 div.replaceWith(message)
600 await render.content(hash, blob, content, null, parsedYaml)
601 }
602 } else if (getimg) {
603 getimg.src = blob
604 }
605 await flushPendingReplies(hash)
606 perfEnd(token)
607}
608
609render.shouldWe = async (blob) => {
610 const authorKey = blob?.substring(0, 44)
611 if (authorKey && await isBlockedAuthor(authorKey)) {
612 return
613 }
614 const [opened, hash] = await Promise.all([
615 apds.open(blob),
616 apds.hash(blob)
617 ])
618 if (!opened) {
619 const yaml = await apds.parseYaml(blob)
620 if (yaml) {
621 await queueLinkedHashes(yaml)
622 }
623 return
624 }
625 const contentHash = opened.substring(13)
626 const msg = await apds.get(contentHash)
627 if (msg) {
628 const yaml = await apds.parseYaml(msg)
629 await queueLinkedHashes(yaml)
630 } else {
631 queueSend(contentHash, { priority: 'high' })
632 }
633 const already = await apds.get(hash)
634 if (!already) {
635 await apds.make(blob)
636 }
637 const inDom = document.getElementById(hash)
638 if (opened && !inDom) {
639 await noteSeen(blob.substring(0, 44))
640 let yaml = null
641 const msg = await apds.get(opened.substring(13))
642 if (msg) {
643 yaml = await apds.parseYaml(msg)
644 if (yaml && yaml.edit) {
645 queueEditRefresh(yaml.edit)
646 }
647 }
648 const ts = parseOpenedTimestamp(opened)
649 const scroller = document.getElementById('scroller')
650 const replyTo = getReplyParent(yaml)
651 if (replyTo) {
652 addReplyToIndex(replyTo, hash, ts, opened)
653 updateReplyCount(replyTo)
654 const wrapper = document.getElementById(replyTo)
655 if (wrapper && wrapper.dataset.repliesLoaded === 'true') {
656 await appendReply(replyTo, hash, ts, blob, opened)
657 } else if (wrapper) {
658 observeReplies(wrapper, replyTo)
659 }
660 return
661 }
662 if (scroller && window.__feedEnqueueMatching) {
663 const queued = await window.__feedEnqueueMatching({
664 hash,
665 ts,
666 blob,
667 opened,
668 author: authorKey
669 })
670 if (queued) { return }
671 }
672 }
673}
674
675render.hash = (hash, row = null) => {
676 if (!hash) { return null }
677 if (!document.getElementById(hash)) {
678 const messageShell = h('div', {classList: 'message-shell premessage'})
679 const replies = h('div', {classList: 'message-replies'})
680 const wrapper = h('div', {id: hash, classList: 'message-wrapper'}, [
681 messageShell,
682 replies
683 ])
684 if (row) {
685 render.applyRowPreview(wrapper, row)
686 }
687 return wrapper
688 }
689 return null
690}
691
692render.insertByTimestamp = (container, hash, ts) => _insertByTimestamp(container, hash, ts)