diff --git a/.gitignore b/.gitignore index c2658d7..58ddee7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +.deployed-commit diff --git a/index.js b/index.js index 4a93f77..d802e61 100644 --- a/index.js +++ b/index.js @@ -24,7 +24,10 @@ var { renderShowAll, renderRssItem, wrapRss, + renderItemGrid, } = require('./render') +var getFollows = require('./lib/follows') +var createItemsHandler = require('./items') var appHash = hash([fs.readFileSync(__filename)]) @@ -112,6 +115,18 @@ exports.init = function (sbot, config) { var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot) var serveAcmeChallenge = require('ssb-acme-validator')(sbot) + var serveItems = createItemsHandler({ + sbot: sbot, + defaultOpts: defaultOpts, + getMsg: getMsg, + getAbout: getAbout, + addAuthorAbout: addAuthorAbout, + renderItemGrid: renderItemGrid, + wrapPage: wrapPage, + respond: respond, + toPull: toPull, + }) + http.createServer(serve).listen(port, host, function () { if (/:/.test(host)) host = '[' + host + ']' console.log('[viewer] Listening on http://' + host + ':' + port) @@ -127,6 +142,7 @@ exports.init = function (sbot, config) { if (m[4] === '/robots.txt') return serveRobots(req, res, conf) if (req.url.startsWith('/static/')) return serveStatic(req, res, m[4]) if (req.url.startsWith('/emoji/')) return serveEmoji(req, res, m[4]) + if (m[4] === '/items') return serveItems(req, res, m[5]) if (req.url.startsWith('/user-feed/')) return serveUserFeed(req, res, m[4]) else if (req.url.startsWith('/channel/')) return serveChannel(req, res, m[4]) else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res) @@ -146,6 +162,23 @@ exports.init = function (sbot, config) { return respond(res, 404, 'Not found') } + // The bare id-lookup form this used to render was a dead end for anyone + // arriving without an id already in hand. Keep the ?id= redirect, then show + // the item grid instead of an empty box. + function serveHome(req, res, query) { + var q = query ? qs.parse(query) : {} + var id = asLink(q.id) + if (id) { + res.writeHead(303, { + Location: '/' + ( + id[0] === '#' ? 'channel/' + id.substr(1) : + refs.isMsgId(id) ? encodeURIComponent(id) : id) + }) + return res.end() + } + return serveItems(req, res, query) + } + function serveFeed(req, res, feedId, ext) { console.log('serving feed: ' + feedId) @@ -226,40 +259,12 @@ exports.init = function (sbot, config) { var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100) console.log('serving user feed: ' + feedId) - var following = [] - var channelSubscriptions = [] - getAbout(feedId, function (err, about) { - pull( - sbot.createUserStream({ id: feedId }), - pull.filter((msg) => { - return !msg.value || - msg.value.content.type == 'contact' || - (msg.value.content.type == 'channel' && - typeof msg.value.content.subscribed != 'undefined') - }), - pull.collect(function (err, msgs) { - msgs.forEach((msg) => { - if (msg.value.content.type == 'contact') - { - if (msg.value.content.following) - following[msg.value.content.contact] = 1 - else - delete following[msg.value.content.contact] - } - else // channel subscription - { - if (msg.value.content.subscribed) - channelSubscriptions[msg.value.content.channel] = 1 - else - delete channelSubscriptions[msg.value.content.channel] - } - }) - - serveFeeds(req, res, following, channelSubscriptions, feedId, - 'user feed ' + (about ? about.name : '')) - }) - ) + getFollows(sbot, feedId, function (err, sets) { + if (err) return respond(res, 500, err.stack || err) + serveFeeds(req, res, sets.following, sets.channelSubscriptions, feedId, + 'user feed ' + (about ? about.name : '')) + }) }) } @@ -613,32 +618,6 @@ function asLink(id) { if (refs.isLink(id)) return id } -function serveHome(req, res, query, conf) { - var q = query ? qs.parse(query) : {} - var id = asLink(q.id) - if (id) { - res.writeHead(303, { - Location: '/' + ( - id[0] === '#' ? 'channel/' + id.substr(1) : - refs.isMsgId(id) ? encodeURIComponent(id) : id) - }) - return res.end() - } - res.writeHead(200, { - 'Content-Type': 'text/html' - }) - pull( - pull.once(h('form', {method: 'get', action: ''}, - h('input', {name: 'id', placeholder: 'id', size: 60, value: q.id || ''}), ' ', - h('input', {type: 'submit', value: 'Go'}) - ).outerHTML), - wrapPage('ssb-viewer'), - toPull(res, function (err) { - if (err) console.error('[viewer]', err) - }) - ) -} - function serveRobots(req, res, conf) { var disallow = conf.disallowRobots == null ? true : conf.disallowRobots res.end('User-agent: *\n' diff --git a/items.js b/items.js new file mode 100644 index 0000000..0344eea --- /dev/null +++ b/items.js @@ -0,0 +1,175 @@ +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 diff --git a/lib/follows.js b/lib/follows.js new file mode 100644 index 0000000..2ffee9a --- /dev/null +++ b/lib/follows.js @@ -0,0 +1,38 @@ +var pull = require('pull-stream') + +// Reduce a feed's own contact and channel-subscription messages into the sets +// they describe. createUserStream runs oldest-first, so a later unfollow +// correctly undoes an earlier follow. +// +// Both /user-feed/ and /items are built on this: "who does this feed listen +// to" is the whole selection rule, which is what lets a new kiosk show up by +// being followed rather than by being added to a list somewhere. +module.exports = function getFollows (sbot, feedId, cb) { + var following = Object.create(null) + var channelSubscriptions = Object.create(null) + + pull( + sbot.createUserStream({ id: feedId }), + pull.filter(function (msg) { + var c = msg && msg.value && msg.value.content + if (!c || typeof c !== 'object') return false // also skips private (string) content + return c.type === 'contact' || + (c.type === 'channel' && typeof c.subscribed !== 'undefined') + }), + pull.drain(function (msg) { + var c = msg.value.content + if (c.type === 'contact') { + if (!c.contact) return + if (c.following) following[c.contact] = true + else delete following[c.contact] + } else { + if (!c.channel) return + if (c.subscribed) channelSubscriptions[c.channel] = true + else delete channelSubscriptions[c.channel] + } + }, function (err) { + if (err) return cb(err) + cb(null, { following: following, channelSubscriptions: channelSubscriptions }) + }) + ) +} diff --git a/render.js b/render.js index ff1ca8a..1bf4a3e 100644 --- a/render.js +++ b/render.js @@ -17,6 +17,7 @@ exports.MdRenderer = MdRenderer exports.renderEmoji = renderEmoji exports.formatMsgs = formatMsgs exports.renderThread = renderThread +exports.renderItemGrid = renderItemGrid exports.renderAbout = renderAbout exports.renderShowAll = renderShowAll exports.renderRssItem = renderRssItem @@ -172,10 +173,21 @@ function renderRssItem(opts) { ) } -const gitHead = proc.spawnSync('git', ['rev-parse', 'HEAD'], { - encoding: 'utf8', - cwd: __dirname -}).stdout.trim() +// Which commit is actually running. Deploys are rsync, not `git pull`, so the +// checkout on the server keeps whatever HEAD it was cloned at and `git +// rev-parse` there would name a commit that has nothing to do with these files. +// bin/deploy-viewer.sh writes the real one to .deployed-commit; fall back to +// git only when running straight from a working copy. +const gitHead = (function () { + try { + var stamped = fs.readFileSync(path.join(__dirname, '.deployed-commit'), 'utf8').trim() + if (stamped) return stamped + } catch (e) { /* not a deploy, ask git */ } + return proc.spawnSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + cwd: __dirname + }).stdout.trim() +}()) const gitHeadShort = gitHead && gitHead.substr(0, 7) const commitUrl = pkg.homepage && pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead @@ -192,7 +204,7 @@ function wrapPage(id) { "" + "" + "" + - id + " | ssb-viewer" + + id + " | custo" + "" + '' + styles + @@ -339,6 +351,77 @@ var styles = ` font-size: 14px; color: #868e96; } + + .items-header { max-width: 60rem; margin: 0 auto 24px; text-align: center; } + .items-header h1 { margin: 0 0 4px; font-size: 1.6em; letter-spacing: 0.04em; } + .items-count { margin: 0 0 14px; color: #868e96; font-size: 14px; } + .items-search input[type=submit] { cursor: pointer; } + + main.item-grid { + max-width: 60rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; + } + /* the single-column rules above target articles; cards are anchors */ + .item-card { + display: flex; + flex-direction: column; + background-color: white; + border-radius: 3px; + box-shadow: 0 1px 3px #949494; + overflow: hidden; + text-decoration: none; + color: #212529; + } + .item-card:hover { box-shadow: 0 2px 8px #6c757d; } + .item-media { position: relative; } + .item-photo { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + background-color: #e9ecef; + } + .item-photo-missing { + display: flex; + align-items: center; + justify-content: center; + color: #adb5bd; + font-size: 12px; + text-align: center; + } + .item-photo-missing::after { content: "photo not fetched yet"; } + .item-card-give .item-photo { opacity: 0.55; } + .item-badge { + position: absolute; + left: 0; right: 0; bottom: 0; + padding: 6px 8px; + background-color: rgba(33, 37, 41, 0.82); + color: #f1f3f5; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .item-meta { padding: 10px 12px; display: flex; flex-direction: column; gap: 3px; } + .item-caption { + line-height: 1.35em; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + .item-by { color: #495057; font-size: 13px; } + .item-meta time { color: #868e96; font-size: 12px; } + .items-more { + max-width: 60rem; + margin: 20px auto 0; + text-align: center; + display: flex; + gap: 20px; + justify-content: center; + } ` @@ -633,3 +716,99 @@ function renderShowAll(showAll, url) { if (!showAll) return '
' + h('a', { href : url + '?showAll' }, 'Show whole feed').outerHTML } + +// --------------------------------------------------------------------------- +// The custo item grid (/items) +// +// Cards, not articles: this is a collection of things, and a single-column feed +// of 300 photos reads like a mailing list rather than an archive. A transfer +// borrows the photo of the item it transfers, so the grid stays regular. +// --------------------------------------------------------------------------- + +function renderItemGrid(opts, meta) { + return pull( + pull.map(renderItemCard.bind(this, opts)), + wrap(toolTipTop() + itemsHeader(opts, meta) + '
', + '
' + itemsFooter(opts, meta) + callToAction()) + ) +} + +function itemsHeader(opts, meta) { + var counted = meta.total === 1 ? '1 item' : meta.total + ' items and transfers' + var feeds = meta.feeds === 1 ? '1 feed' : meta.feeds + ' feeds' + return h('header.items-header', + h('h1', 'custo items'), + h('p.items-count', counted + ' from ' + feeds), + h('form.items-search', { method: 'get', action: opts.base }, + h('input', { name: 'id', size: 34, + placeholder: 'paste a message or feed id' }), ' ', + h('input', { type: 'submit', value: 'Go' })) + ).outerHTML +} + +function itemsFooter(opts, meta) { + var links = [] + if (meta.older) + links.push(h('a', { href: opts.base + 'items?before=' + meta.older }, 'older items')) + if (!meta.showAll && meta.total > meta.shown) + links.push(h('a', { href: opts.base + 'items?showAll' }, 'show everything')) + if (!links.length) return '' + return h('div.items-more', links).outerHTML +} + +function renderItemCard(opts, msg) { + var c = msg.value.content + var isGive = c.nft === 'give' + + // A transfer links to the item it transfers, not to itself: the thread page + // shows the mint and every hand-off since, which is the interesting view. + var target = isGive && c.root ? c.root : msg.key + + var media + if (msg.itemPhoto) { + media = h('img.item-photo', { src: opts.img_base + msg.itemPhoto, alt: '' }) + // hyperscript assigns these as DOM properties, which outerHTML then drops, + // so they have to be set as attributes explicitly. + media.setAttribute('loading', 'lazy') + media.setAttribute('decoding', 'async') + // 267 blobs are held against 301 mints, so some photos genuinely are not + // here yet. Fall back to the placeholder rather than a broken-image icon. + media.setAttribute('onerror', + "this.className='item-photo item-photo-missing';this.removeAttribute('src')") + } else { + media = h('div.item-photo.item-photo-missing') + } + + var badge = isGive + ? h('span.item-badge', '\u2192 ' + stewardName(msg, c)) + : '' + + var caption = msg.itemCaption || (isGive ? 'an item' : itemFallbackCaption(c)) + + return h('a.item-card' + (isGive ? '.item-card-give' : ''), + { href: opts.base + encodeURIComponent(target) }, + h('div.item-media', media, badge), + h('div.item-meta', + h('span.item-caption', caption), + h('span.item-by', msg.author && msg.author.name), + itemTime(msg)) + ).outerHTML +} + +function stewardName(msg, c) { + if (msg.stewardAbout && msg.stewardAbout.name) return msg.stewardAbout.name + return String(c.target || 'someone').substr(0, 10) + '\u2026' +} + +function itemFallbackCaption(c) { + return String(c.text || '').replace(/\s+/g, ' ').trim().substr(0, 140) || 'untitled' +} + +// Like msgTimestamp, but without the nested \u2014 the whole card is a link. +function itemTime(msg) { + var date = new Date(msg.value.timestamp) + var isoStr = date.toISOString() + return h('time.ssb-timestamp', + { datetime: isoStr, title: isoStr }, + formatDate(date)) +}