Files
custo-viewer/items.js
T
travandClaude Opus 5 fc9539a618 /items shows items only, not custody transfers
A `nft: "give"` message is a change of custody, not a thing. It has no photo of
its own, so it borrowed the photo of the item it transferred - which meant the
same object appeared on the grid twice, a few cards apart, distinguished only
by a dimmed image and an id overlay. That reads as a duplicate, not as a
hand-off.

Custody is better answered one level down. Each card already links to its
item's thread, where the mint and every hand-off since are in order, with the
steward resolved. So the overlay was showing a truncated feed id on the grid to
save a click that is worth making.

Filters on nft === "mint" rather than excluding gives: every custodisco message
carries one of the two - checked across all 407 - so matching mint is exact.
301 items from 3 feeds, down from 407 entries.

The feed count now counts feeds that actually contributed an item rather than
the size of the follow set. The pub follows itself and publishes no items, so
the old number was one too high and would have drifted further as feeds get
followed for other reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192zBTNZKZn5svyJ5HTnYds
2026-08-22 20:30:46 -04:00

163 lines
5.9 KiB
JavaScript

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