Compare commits
3
Commits
996e45379a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09798f7146 | ||
|
|
fc9539a618 | ||
|
|
cc0ad8c1a8 |
@@ -1 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.deployed-commit
|
||||||
|
|||||||
@@ -42,6 +42,52 @@ Two things to know before writing a query against this:
|
|||||||
Messages are published by the kiosks (`/home/trav/custodisco-kiosk/ssb-post.sh`), not by
|
Messages are published by the kiosks (`/home/trav/custodisco-kiosk/ssb-post.sh`), not by
|
||||||
this viewer. The viewer is read-only.
|
this viewer. The viewer is read-only.
|
||||||
|
|
||||||
|
## Changing how pages look
|
||||||
|
|
||||||
|
Everything visual is in **`render.js`**. There is no template directory and no CSS
|
||||||
|
files to hunt through — pages are built as strings and there is exactly one `<style>`
|
||||||
|
block, near the middle of the file.
|
||||||
|
|
||||||
|
Find things by searching for the text you can see on the page. `grep -n` on a phrase
|
||||||
|
from the rendered page lands you on the line that produces it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "Join Scuttlebutt" render.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The pieces, in the order you meet them on a page:
|
||||||
|
|
||||||
|
| What you see | Where |
|
||||||
|
|---|---|
|
||||||
|
| `<title>` and the whole `<head>` | `wrapPage()` |
|
||||||
|
| "custo items", the count, the search box | `itemsHeader()` |
|
||||||
|
| an item card: photo, caption, kiosk, time | `renderItemCard()` |
|
||||||
|
| "older items" / "show everything" | `itemsFooter()` |
|
||||||
|
| the "Join Scuttlebutt now" button | `callToAction()` |
|
||||||
|
| licence, repo link, deployed commit | the `footer` constant |
|
||||||
|
| all colours, spacing, the grid itself | the `styles` template string |
|
||||||
|
|
||||||
|
The grid rules are the `.item-*` selectors in `styles`. Card width is one number —
|
||||||
|
`minmax(190px, 1fr)` in `main.item-grid` — raise it for fewer, larger cards.
|
||||||
|
|
||||||
|
Two gotchas worth knowing before you edit:
|
||||||
|
|
||||||
|
- **Attributes set through `h()` can vanish.** hyperscript assigns anything that looks
|
||||||
|
like a DOM property rather than an attribute, and `outerHTML` then drops it. That is
|
||||||
|
why `loading="lazy"` is applied with `media.setAttribute(...)` instead. If an
|
||||||
|
attribute you added does not appear in the output, this is why.
|
||||||
|
- **Text passed to `h()` is escaped; strings concatenated into `wrap()` are not.**
|
||||||
|
Anything derived from a message must go through `h()`.
|
||||||
|
|
||||||
|
To see a change: edit, then from the ops repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/deploy-viewer.sh --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
which syncs, restarts the viewer and checks `/items` answers before it returns. Commit
|
||||||
|
and push separately — the deploy only moves files.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
`bin.js` connects to a local `ssb-server` and serves on `conf.viewer.port` (8807).
|
`bin.js` connects to a local `ssb-server` and serves on `conf.viewer.port` (8807).
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ var {
|
|||||||
renderShowAll,
|
renderShowAll,
|
||||||
renderRssItem,
|
renderRssItem,
|
||||||
wrapRss,
|
wrapRss,
|
||||||
|
renderItemGrid,
|
||||||
} = require('./render')
|
} = require('./render')
|
||||||
|
var getFollows = require('./lib/follows')
|
||||||
|
var createItemsHandler = require('./items')
|
||||||
|
|
||||||
var appHash = hash([fs.readFileSync(__filename)])
|
var appHash = hash([fs.readFileSync(__filename)])
|
||||||
|
|
||||||
@@ -112,6 +115,16 @@ exports.init = function (sbot, config) {
|
|||||||
var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
|
var getAbout = memo({cache: lru(100)}, require('./lib/about'), sbot)
|
||||||
var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
|
var serveAcmeChallenge = require('ssb-acme-validator')(sbot)
|
||||||
|
|
||||||
|
var serveItems = createItemsHandler({
|
||||||
|
sbot: sbot,
|
||||||
|
defaultOpts: defaultOpts,
|
||||||
|
addAuthorAbout: addAuthorAbout,
|
||||||
|
renderItemGrid: renderItemGrid,
|
||||||
|
wrapPage: wrapPage,
|
||||||
|
respond: respond,
|
||||||
|
toPull: toPull,
|
||||||
|
})
|
||||||
|
|
||||||
http.createServer(serve).listen(port, host, function () {
|
http.createServer(serve).listen(port, host, function () {
|
||||||
if (/:/.test(host)) host = '[' + host + ']'
|
if (/:/.test(host)) host = '[' + host + ']'
|
||||||
console.log('[viewer] Listening on http://' + host + ':' + port)
|
console.log('[viewer] Listening on http://' + host + ':' + port)
|
||||||
@@ -127,6 +140,7 @@ exports.init = function (sbot, config) {
|
|||||||
if (m[4] === '/robots.txt') return serveRobots(req, res, conf)
|
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('/static/')) return serveStatic(req, res, m[4])
|
||||||
if (req.url.startsWith('/emoji/')) return serveEmoji(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])
|
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('/channel/')) return serveChannel(req, res, m[4])
|
||||||
else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
|
else if (req.url.startsWith('/.well-known/acme-challenge')) return serveAcmeChallenge(req, res)
|
||||||
@@ -146,6 +160,23 @@ exports.init = function (sbot, config) {
|
|||||||
return respond(res, 404, 'Not found')
|
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) {
|
function serveFeed(req, res, feedId, ext) {
|
||||||
console.log('serving feed: ' + feedId)
|
console.log('serving feed: ' + feedId)
|
||||||
|
|
||||||
@@ -226,40 +257,12 @@ exports.init = function (sbot, config) {
|
|||||||
var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
|
var feedId = url.substring(url.lastIndexOf('user-feed/')+10, 100)
|
||||||
console.log('serving user feed: ' + feedId)
|
console.log('serving user feed: ' + feedId)
|
||||||
|
|
||||||
var following = []
|
|
||||||
var channelSubscriptions = []
|
|
||||||
|
|
||||||
getAbout(feedId, function (err, about) {
|
getAbout(feedId, function (err, about) {
|
||||||
pull(
|
getFollows(sbot, feedId, function (err, sets) {
|
||||||
sbot.createUserStream({ id: feedId }),
|
if (err) return respond(res, 500, err.stack || err)
|
||||||
pull.filter((msg) => {
|
serveFeeds(req, res, sets.following, sets.channelSubscriptions, feedId,
|
||||||
return !msg.value ||
|
'user feed ' + (about ? about.name : ''))
|
||||||
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 : ''))
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,32 +616,6 @@ function asLink(id) {
|
|||||||
if (refs.isLink(id)) return 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) {
|
function serveRobots(req, res, conf) {
|
||||||
var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
|
var disallow = conf.disallowRobots == null ? true : conf.disallowRobots
|
||||||
res.end('User-agent: *\n'
|
res.end('User-agent: *\n'
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ exports.MdRenderer = MdRenderer
|
|||||||
exports.renderEmoji = renderEmoji
|
exports.renderEmoji = renderEmoji
|
||||||
exports.formatMsgs = formatMsgs
|
exports.formatMsgs = formatMsgs
|
||||||
exports.renderThread = renderThread
|
exports.renderThread = renderThread
|
||||||
|
exports.renderItemGrid = renderItemGrid
|
||||||
exports.renderAbout = renderAbout
|
exports.renderAbout = renderAbout
|
||||||
exports.renderShowAll = renderShowAll
|
exports.renderShowAll = renderShowAll
|
||||||
exports.renderRssItem = renderRssItem
|
exports.renderRssItem = renderRssItem
|
||||||
@@ -119,24 +120,31 @@ function wrap(before, after) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page furniture. These are the bits of chrome wrapped around every page, and
|
||||||
|
// they are the things most likely to want changing.
|
||||||
|
//
|
||||||
|
// callToAction() the "Join Scuttlebutt now" button at the foot of a page
|
||||||
|
// footer just below it: licence, repo name, deployed commit
|
||||||
|
// styles the single <style> block; .item-* rules are the grid
|
||||||
|
// itemsHeader() the /items title, count and search box
|
||||||
|
//
|
||||||
|
// Removed already: toolTipTop(), which printed "You are reading content from
|
||||||
|
// Scuttlebutt" above every page. It is in the git history if it is ever wanted
|
||||||
|
// back.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function callToAction() {
|
function callToAction() {
|
||||||
return h('a.call-to-action',
|
return h('a.call-to-action',
|
||||||
{ href: 'https://www.scuttlebutt.nz' },
|
{ href: 'https://www.scuttlebutt.nz' },
|
||||||
'Join Scuttlebutt now').outerHTML
|
'Join Scuttlebutt now').outerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolTipTop() {
|
|
||||||
return h('span.top-tip',
|
|
||||||
'You are reading content from ',
|
|
||||||
h('a', { href: 'https://www.scuttlebutt.nz' },
|
|
||||||
'Scuttlebutt')).outerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAbout(opts, about, showAllHTML = "") {
|
function renderAbout(opts, about, showAllHTML = "") {
|
||||||
if (about.publicWebHosting === false || (about.publicWebHosting == null && opts.requireOptIn)) {
|
if (about.publicWebHosting === false || (about.publicWebHosting == null && opts.requireOptIn)) {
|
||||||
return pull(
|
return pull(
|
||||||
pull.map(renderMsg.bind(this, opts, '')),
|
pull.map(renderMsg.bind(this, opts, '')),
|
||||||
wrap(toolTipTop() + '<main>', '</main>' + callToAction())
|
wrap('<main>', '</main>' + callToAction())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +152,7 @@ function renderAbout(opts, about, showAllHTML = "") {
|
|||||||
figCaption.innerHTML = 'Feed of ' + escape(about.name) + '<br>' + marked(String(about.description || ''), opts.marked)
|
figCaption.innerHTML = 'Feed of ' + escape(about.name) + '<br>' + marked(String(about.description || ''), opts.marked)
|
||||||
return pull(
|
return pull(
|
||||||
pull.map(renderMsg.bind(this, opts, '')),
|
pull.map(renderMsg.bind(this, opts, '')),
|
||||||
wrap(toolTipTop() + '<main>' +
|
wrap('<main>' +
|
||||||
h('article',
|
h('article',
|
||||||
h('header',
|
h('header',
|
||||||
h('figure',
|
h('figure',
|
||||||
@@ -161,7 +169,7 @@ function renderAbout(opts, about, showAllHTML = "") {
|
|||||||
function renderThread(opts, id, showAllHTML = "") {
|
function renderThread(opts, id, showAllHTML = "") {
|
||||||
return pull(
|
return pull(
|
||||||
pull.map(renderMsg.bind(this, opts, id)),
|
pull.map(renderMsg.bind(this, opts, id)),
|
||||||
wrap(toolTipTop() + '<main>',
|
wrap('<main>',
|
||||||
showAllHTML + '</main>' + callToAction())
|
showAllHTML + '</main>' + callToAction())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -172,10 +180,21 @@ function renderRssItem(opts) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitHead = proc.spawnSync('git', ['rev-parse', 'HEAD'], {
|
// Which commit is actually running. Deploys are rsync, not `git pull`, so the
|
||||||
encoding: 'utf8',
|
// checkout on the server keeps whatever HEAD it was cloned at and `git
|
||||||
cwd: __dirname
|
// rev-parse` there would name a commit that has nothing to do with these files.
|
||||||
}).stdout.trim()
|
// 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 gitHeadShort = gitHead && gitHead.substr(0, 7)
|
||||||
const commitUrl = pkg.homepage &&
|
const commitUrl = pkg.homepage &&
|
||||||
pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead
|
pkg.homepage.replace(/\/+$/, '') + '/commit/' + gitHead
|
||||||
@@ -192,7 +211,7 @@ function wrapPage(id) {
|
|||||||
"<!doctype html><html><head>" +
|
"<!doctype html><html><head>" +
|
||||||
"<meta charset=utf-8>" +
|
"<meta charset=utf-8>" +
|
||||||
"<title>" +
|
"<title>" +
|
||||||
id + " | ssb-viewer" +
|
id + " | custo" +
|
||||||
"</title>" +
|
"</title>" +
|
||||||
'<meta name=viewport content="width=device-width,initial-scale=1">' +
|
'<meta name=viewport content="width=device-width,initial-scale=1">' +
|
||||||
styles +
|
styles +
|
||||||
@@ -227,15 +246,6 @@ var styles = `
|
|||||||
}
|
}
|
||||||
a { color: #364fc7; }
|
a { color: #364fc7; }
|
||||||
|
|
||||||
.top-tip, .top-tip a {
|
|
||||||
color: #868e96;
|
|
||||||
}
|
|
||||||
.top-tip {
|
|
||||||
text-align: center;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
main { margin: 0 auto; max-width: 40rem; }
|
main { margin: 0 auto; max-width: 40rem; }
|
||||||
main article:first-child { border-radius: 3px 3px 0 0; }
|
main article:first-child { border-radius: 3px 3px 0 0; }
|
||||||
main article:last-child { border-radius: 0 0 3px 3px; }
|
main article:last-child { border-radius: 0 0 3px 3px; }
|
||||||
@@ -339,6 +349,65 @@ var styles = `
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #868e96;
|
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-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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -633,3 +702,87 @@ function renderShowAll(showAll, url) {
|
|||||||
if (!showAll)
|
if (!showAll)
|
||||||
return '<br>' + h('a', { href : url + '?showAll' }, 'Show whole feed').outerHTML
|
return '<br>' + 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. Items only -
|
||||||
|
// who holds an item is answered by its thread, one click in, not by an overlay
|
||||||
|
// on the grid.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderItemGrid(opts, meta) {
|
||||||
|
return pull(
|
||||||
|
pull.map(renderItemCard.bind(this, opts)),
|
||||||
|
wrap(itemsHeader(opts, meta) + '<main class="item-grid">',
|
||||||
|
'</main>' + itemsFooter(opts, meta) + callToAction())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsHeader(opts, meta) {
|
||||||
|
var counted = meta.total === 1 ? '1 item' : meta.total + ' items'
|
||||||
|
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 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 caption = msg.itemCaption || itemFallbackCaption(c)
|
||||||
|
|
||||||
|
// Links to the item's own thread: the mint, and every hand-off since.
|
||||||
|
return h('a.item-card',
|
||||||
|
{ href: opts.base + encodeURIComponent(msg.key) },
|
||||||
|
h('div.item-media', media),
|
||||||
|
h('div.item-meta',
|
||||||
|
h('span.item-caption', caption),
|
||||||
|
h('span.item-by', msg.author && msg.author.name),
|
||||||
|
itemTime(msg))
|
||||||
|
).outerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFallbackCaption(c) {
|
||||||
|
return String(c.text || '').replace(/\s+/g, ' ').trim().substr(0, 140) || 'untitled'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like msgTimestamp, but without the nested <a> \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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user