a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 692 lines 22 kB view raw
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)