// postgate — who may QUOTE a post (app.bsky.feed.postgate).
//
// Same rkey binding as a threadgate: the record's rkey is IDENTICAL to the post's, and `post` carries the
// post's at:// uri. So it needs no index either — given a post we know the rkey.
//
// ⚠ THE POLARITY IS INVERTED RELATIVE TO A THREADGATE, and that is the whole trap. A threadgate carries
// `allow` — a list of who MAY reply, where an empty array means nobody. A postgate carries
// `embeddingRules` — a list of RESTRICTIONS, where an empty array means NO restrictions, i.e. anyone may
// quote. So:
//
// threadgate allow: [] -> nobody may reply
// postgate embeddingRules: [] -> ANYONE may quote
//
// The two look alike and mean opposite things. Writing one with the other's polarity silently produces
// the reverse of what the author asked for, and nothing errors.
//
// There is one rule today, #disableRule: quoting off entirely. `detachedEmbeddingUris` is the other half
// of the lexicon — specific existing quotes the author has pulled down — and is preserved on read so a
// future writer does not clobber it.
export const QUOTE_CHOICES = ['anyone', 'nobody']
// A UI choice -> the record, or null for 'anyone'. Like a threadgate's 'everyone', 'anyone' writes NO
// RECORD rather than one with an empty rule list: the absent record and the empty list happen to mean the
// same thing here, but writing nothing is unambiguous and leaves no artefact to misread later.
export function postgateRecord ({ postUri, choice, detachedEmbeddingUris = [], createdAt = new Date().toISOString() }) {
if (choice !== 'nobody') return null
return {
$type: 'app.bsky.feed.postgate',
post: postUri,
createdAt,
detachedEmbeddingUris,
embeddingRules: [{ $type: 'app.bsky.feed.postgate#disableRule' }]
}
}
// The inverse, for rendering. An unrecognised rule reads as 'limited' — never guessed permissive, since
// assuming an unknown restriction is harmless is how a protected post ends up quotable.
export function quoteChoiceOf (record) {
if (!record) return 'anyone'
const rules = record.embeddingRules
if (!Array.isArray(rules) || rules.length === 0) return 'anyone' // empty = NO restrictions
const types = rules.map((r) => String((r && r.$type) || ''))
if (types.some((t) => t.endsWith('#disableRule'))) return 'nobody'
return 'limited'
}
// May `viewerDid` quote a post by `authorDid`? The author may always quote their own — that is how a
// self-thread of quotes works, and every client behaves that way.
export function canQuote ({ viewerDid, authorDid, record } = {}) {
if (!viewerDid) return false
if (viewerDid === authorDid) return true
const c = quoteChoiceOf(record)
if (c === 'anyone') return true
// 'nobody' and 'limited' both fail closed for OUR writes: posting a quote the author asked to prevent
// is worse than declining to offer the button.
return false
}
export function describeQuoteGate (choice) {
switch (choice) {
case 'nobody': return 'the author turned off quotes for this post'
case 'limited': return 'quoting this post is limited'
default: return ''
}
}
// Quotes the author has individually detached. Preserved on any rewrite so a later gate change does not
// silently re-attach something they pulled down.
export function detachedUris (record) {
const d = record && record.detachedEmbeddingUris
return Array.isArray(d) ? d.filter((u) => typeof u === 'string') : []
}