var pull = require('pull-stream') var paramap = require('pull-paramap') var qs = require('querystring') var getFollows = require('./lib/follows') // One merged view of every custo item the pub knows about. // // The selection rule is deliberately "whatever this pub follows", not a list of // kiosk feed ids: adding a kiosk means following it from the pub, and its items // appear here on the next cache miss. No code change, no edit to cust.ooo. // // Two facts about the data that this file depends on, both verified against the // live log and both easy to get wrong: // * `custodisco` is the STRING "true", not a boolean. // * no custo message has ever set `content.channel`, so the channel index is // useless here and the filter has to look at content fields directly. var CACHE_MS = 60 * 1000 var PAGE_SIZE = 60 var IMAGE_MD = /!\[[^\]]*\]\((&[A-Za-z0-9+/]{43}=\.sha256)\)/ // The item's photo: a mints's first image mention, falling back to the markdown // embed in the text for messages whose mentions array is missing or unhelpful. function itemPhoto (c) { if (c && Array.isArray(c.mentions)) { for (var i = 0; i < c.mentions.length; i++) { var m = c.mentions[i] if (!m || typeof m.link !== 'string' || m.link[0] !== '&') continue if (typeof m.type === 'string' && !/^image\//.test(m.type)) continue return m.link } } var md = IMAGE_MD.exec(String((c && c.text) || '')) return md ? md[1] : null } // Strip the parts of the post text that are furniture rather than description: // the image embed itself, and the kiosk's " a #custodisco item " sign-off. function itemCaption (c) { return String((c && c.text) || '') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\s*a\s+#custodisco\s+item\s*$/i, ' ') .replace(/\s+/g, ' ') .trim() } function isCustodisco (msg) { var c = msg && msg.value && msg.value.content return !!c && typeof c === 'object' && c.custodisco === 'true' } // ctx supplies the pieces that live in index.js's init closure: // sbot, defaultOpts, getMsg, getAbout, addAuthorAbout, respond, toPull, // renderItemGrid, wrapPage module.exports = function createItemsHandler (ctx) { var cache = null // { at, msgs, feeds } // A full log scan takes well under a second on this node (11k messages, // 360 KB), so this cache exists to keep a page refresh from redoing it, not // because the scan is expensive. function load (cb) { if (cache && Date.now() - cache.at < CACHE_MS) return cb(null, cache) ctx.sbot.whoami(function (err, feed) { if (err) return cb(err) var me = feed.id getFollows(ctx.sbot, me, function (err, sets) { if (err) return cb(err) var authors = sets.following authors[me] = true // the pub's own items count too pull( ctx.sbot.createLogStream({ reverse: true }), pull.filter(function (msg) { return isCustodisco(msg) && authors[msg.value.author] === true }), pull.collect(function (err, msgs) { if (err) return cb(err) // Log order is arrival order, which diverges from publish order // whenever an old feed is backfilled. Sort so "newest first" and // the ?before cursor both mean the same thing. msgs.sort(function (a, b) { return b.value.timestamp - a.value.timestamp }) cache = { at: Date.now(), msgs: msgs, feeds: Object.keys(authors).length } cb(null, cache) }) ) }) }) } // A transfer carries no photo of its own, so borrow the parent item's and // resolve the new steward's display name. Both lookups go through the LRU // memos index.js already keeps. function addTransferContext (msg, cb) { var c = msg.value.content if (c.nft !== 'give') { msg.itemPhoto = itemPhoto(c) msg.itemCaption = itemCaption(c) return cb(null, msg) } var pending = 2 function done () { if (--pending) return cb(null, msg) } if (c.root) { ctx.getMsg(c.root, function (err, root) { var rc = root && root.value && root.value.content if (rc) { msg.itemPhoto = itemPhoto(rc) msg.itemCaption = itemCaption(rc) } done() }) } else done() if (c.target) { ctx.getAbout(c.target, function (err, about) { if (about) msg.stewardAbout = about done() }) } else done() } return function serveItems (req, res, query) { var q = query ? qs.parse(query) : {} var showAll = 'showAll' in q var before = q.before ? Number(q.before) : null load(function (err, data) { if (err) return ctx.respond(res, 500, err.stack || err) var msgs = data.msgs if (before) { msgs = msgs.filter(function (m) { return m.value.timestamp < before }) } var page = showAll ? msgs : msgs.slice(0, PAGE_SIZE) var last = page[page.length - 1] var older = (!showAll && msgs.length > page.length && last) ? last.value.timestamp : null res.writeHead(200, { 'Content-Type': 'text/html' }) pull( pull.values(page), paramap(ctx.addAuthorAbout, 8), paramap(addTransferContext, 8), pull( ctx.renderItemGrid(ctx.defaultOpts, { total: data.msgs.length, shown: page.length, feeds: data.feeds, older: older, showAll: showAll }), ctx.wrapPage('items') ), ctx.toPull(res, function (err) { if (err) console.error('[viewer]', err) }) ) }) } } module.exports.itemPhoto = itemPhoto module.exports.itemCaption = itemCaption module.exports.isCustodisco = isCustodisco