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. // // ITEMS ONLY. A `nft: "give"` message is a change of custody, not a thing - it // has no photo of its own and describes an item already on the page, so showing // it here listed the same object twice. Custody is answered one level down: the // card links to the item's thread, where the mint and every hand-off since are // in order. // // 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() } // Every custodisco message carries nft: "mint" or "give" - checked across all // 407 of them, none lacks the field - so matching "mint" is exact rather than // merely excluding gives. function isItem (msg) { var c = msg && msg.value && msg.value.content return !!c && typeof c === 'object' && c.custodisco === 'true' && c.nft === 'mint' } // ctx supplies the pieces that live in index.js's init closure: // sbot, defaultOpts, 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 isItem(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 }) // Count the feeds that actually contributed an item, not the size // of the follow set: the pub follows itself and publishes no items, // so the follow set overstates it by one and would keep drifting // as feeds are followed for other reasons. var contributing = Object.create(null) msgs.forEach(function (m) { contributing[m.value.author] = true }) cache = { at: Date.now(), msgs: msgs, feeds: Object.keys(contributing).length } cb(null, cache) }) ) }) }) } function addItemContext (msg, cb) { var c = msg.value.content msg.itemPhoto = itemPhoto(c) msg.itemCaption = itemCaption(c) cb(null, msg) } 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(addItemContext, 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.isItem = isItem