What you pay for, what Discord drops, and pixiv switched off #251

Merged
bvandeusen merged 37 commits from dev into main 2026-09-13 12:09:28 -04:00
2 changed files with 359 additions and 24 deletions
Showing only changes of commit eb6e0df858 - Show all commits
+201 -21
View File
@@ -30,40 +30,140 @@
</template>
<!-- FRESH INSTALL: the on-ramp, in the order the steps actually depend on
each other a source cannot fetch anything without a credential. -->
each other a source cannot fetch anything without a credential.
#387 C6 adds the third rung: once a credential exists, FC can just
LOOK UP what the operator already subscribes to instead of making
them retype it. Exactly one rung shows, chosen by `rung` below. -->
<template v-else>
<p class="fc-empty__lead">Nothing here yet.</p>
<p class="fc-empty__sub">
FabledCurator follows the creators you subscribe to and files what they
post. Two steps to start.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-key-variant"
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
>Add a credential</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
<p class="fc-empty__hint">
A credential comes first a source can't fetch anything without your
logged-in session.
</p>
<!-- 1. No credential. Discovery is not yet possible, so it is not
offered a button that cannot work is worse than its absence. -->
<template v-if="rung === 'credential'">
<p class="fc-empty__sub">
FabledCurator follows the creators you subscribe to and files what
they post. Two steps to start.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-key-variant"
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
>Add a credential</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
<p class="fc-empty__hint">
A credential comes first a source can't fetch anything without your
logged-in session.
</p>
</template>
<!-- 2. Credential, roster never synced. The payoff rung. -->
<template v-else-if="rung === 'discover'">
<p class="fc-empty__sub">
You've connected {{ credentialLabel }}. FabledCurator can look up the
creators you already subscribe to, so you don't have to add them by
hand.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-account-search-outline"
:loading="discovering"
@click="discover"
>Find what you already subscribe to</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
<p class="fc-empty__hint">
Nothing is added automatically — you'll get a list to pick from.
</p>
</template>
<!-- 3. Synced, and there is something to adopt. The count is the whole
message; picking happens in C4's card. -->
<template v-else-if="rung === 'adopt'">
<p class="fc-empty__sub">
{{ unmatchedCount }}
{{ unmatchedCount === 1 ? 'creator you subscribe to is' : 'creators you subscribe to are' }}
not being followed here yet.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-account-plus-outline"
:to="{ path: '/subscriptions' }"
>Choose who to follow</v-btn>
</div>
<p class="fc-empty__hint">
Added one at a time, so a fresh install doesn't start a dozen
backfills at once.
</p>
</template>
<!-- 4. Discovery was tried and could not reach the platform. Rule #164:
say so plainly. Not a spinner, not a crash, not a retry button
that will fail the same way. The manual path still works. -->
<template v-else-if="rung === 'unavailable'">
<p class="fc-empty__sub">
FabledCurator couldn't reach {{ credentialLabel }} to look up your
subscriptions{{ discoveryError ? ` (${discoveryError})` : '' }}. You
can still add creators by hand.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-key-variant"
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
>Check the credential</v-btn>
</div>
</template>
<!-- 5. Synced and nothing to discover: the roster is fully tracked, or
the account subscribes to nothing. Either way the manual path is
genuinely the only thing left to offer. -->
<template v-else>
<p class="fc-empty__sub">
Everything you subscribe to on {{ credentialLabel }} is already
followed here. Add a creator by hand to start filling the feed.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useCredentialsStore } from '../../stores/credentials.js'
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
import { useMembershipSyncStore } from '../../stores/membershipSync.js'
import { useSourcesStore } from '../../stores/sources.js'
const store = useSourcesStore()
const { scheduleStatus: status } = storeToRefs(store)
const credentials = useCredentialsStore()
const syncStore = useMembershipSyncStore()
const reconcile = useMembershipReconcileStore()
const { byPlatform } = storeToRefs(credentials)
const { platforms: syncPlatforms } = storeToRefs(syncStore)
const { platforms: reconcilePlatforms } = storeToRefs(reconcile)
// Absent status is treated as "fresh install", which is the safe way round:
// the on-ramp is useful to a first-run operator and merely redundant to an
// established one, whereas "see what's running" shown to someone with nothing
@@ -75,11 +175,91 @@ const sourceCountLabel = computed(() => {
return `${n} ${n === 1 ? 'source' : 'sources'}`
})
// --- #387 C6: which on-ramp rung this install is actually on ---------------
//
// ONE predicate, not four independent v-ifs. The rungs are mutually exclusive
// by construction — an install is at exactly one point in the loop — and four
// separate conditions would eventually let two of them render at once, which
// on the first screen a new installer ever sees is the worst place for it.
const hasCredential = computed(() => byPlatform.value.size > 0)
// Any platform that has ever completed a sweep. NULL last_success_at means
// NEVER, which the C3 endpoint is deliberately careful to preserve — rendering
// it as 0 is the conflation that whole surface exists to prevent.
const syncedPlatform = computed(
() => syncPlatforms.value.find((p) => p.last_success_at) || null,
)
// Errored WITHOUT ever having succeeded. A platform that synced once and
// failed since is not "unavailable" — it has a roster, just an ageing one, and
// C4's freshness gate is what handles that. This is specifically the
// never-worked case, which is what an install with no outbound network looks
// like (rule #164).
const discoveryError = computed(() => {
if (syncedPlatform.value) return null
const failed = syncPlatforms.value.find((p) => p.last_error_type)
return failed ? failed.last_error_type : null
})
const unmatchedCount = computed(() =>
reconcilePlatforms.value.reduce(
(n, p) => n + (p.subscribed_not_tracked?.length || 0), 0,
),
)
// Which platforms the operator has connected, for the copy. Named rather than
// counted: "you've connected patreon" is a sentence about their setup, where
// "1 credential" is a sentence about our data model.
const credentialLabel = computed(() => {
const names = [...byPlatform.value.keys()]
if (names.length === 0) return 'your platform'
if (names.length === 1) return names[0]
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
})
const rung = computed(() => {
// Absent data reads as "no credential", the same safe-direction default B4
// chose for hasSources: the first rung is useful to a genuine fresh install
// and merely redundant to anyone further along, whereas a discovery button
// shown to someone with no credential is a dead end.
if (!hasCredential.value) return 'credential'
if (discoveryError.value) return 'unavailable'
if (!syncedPlatform.value) return 'discover'
return unmatchedCount.value > 0 ? 'adopt' : 'manual'
})
const discovering = ref(false)
async function discover () {
// The sweep is queued, not inline (C3) — so this cannot report a result, and
// must not pretend to. syncNow() raises a toast saying it runs in the
// background; re-reading the state is what eventually moves the rung.
discovering.value = true
try {
await syncStore.syncNow()
await syncStore.load().catch(() => {})
} finally {
discovering.value = false
}
}
// Bucket 1's count is only meaningful once something has synced, so it is
// fetched then rather than on every empty render. `immediate` covers the case
// where the sync state arrived before this watcher was set up.
watch(syncedPlatform, (p) => {
if (p && reconcilePlatforms.value.length === 0) reconcile.load().catch(() => {})
}, { immediate: true })
onMounted(() => {
// The ribbon may already have loaded this on the front door; in Browse's
// Posts tab nothing has. Failure is swallowed — an empty feed must still
// explain itself when the status call is unavailable (rule #164).
// explain itself when the status call is unavailable (rule #164). The same
// goes for every call below: this screen renders on an install that can
// reach nothing, and a rejected promise here would leave it blank.
if (!status.value) store.loadScheduleStatus().catch(() => {})
if (byPlatform.value.size === 0) credentials.loadAll().catch(() => {})
if (syncPlatforms.value.length === 0) syncStore.load().catch(() => {})
})
</script>
+158 -3
View File
@@ -2,6 +2,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
import { useCredentialsStore } from '../../src/stores/credentials.js'
import { useMembershipReconcileStore } from '../../src/stores/membershipReconcile.js'
import { useMembershipSyncStore } from '../../src/stores/membershipSync.js'
import { useSourcesStore } from '../../src/stores/sources.js'
import { mountWithStore } from '../support/mountComponent.js'
@@ -10,9 +13,45 @@ import { mountWithStore } from '../support/mountComponent.js'
// "add a source" when they already have three and are mid-backfill is worse
// than saying nothing — it reads as the app not knowing its own state.
const mountWith = (status) => mountWithStore(FeedEmptyState, () => {
useSourcesStore().scheduleStatus = status
})
// The second argument is C6's world. Omitting it means "no credential", which
// is both the fresh-install default and the rung every B4 case above asserts —
// so those keep passing unchanged rather than needing the new vocabulary.
const mountWith = (status, { credentials = [], sync = [], reconcile = [] } = {}) =>
mountWithStore(FeedEmptyState, () => {
useSourcesStore().scheduleStatus = status
useCredentialsStore().byPlatform = new Map(
credentials.map((name) => [name, { platform: name }]),
)
useMembershipSyncStore().platforms = sync
useMembershipReconcileStore().platforms = reconcile
})
const EMPTY = { total_sources: 0 }
const SYNCED = [{ platform: 'patreon', last_success_at: '2026-09-12T00:00:00Z' }]
const NEVER = [{ platform: 'patreon', last_success_at: null }]
// Every rung's distinguishing phrase, so a test can assert that exactly ONE is
// on screen. Each is chosen to sit on a SINGLE template line: a phrase spanning
// a line break would never match once the renderer keeps the newline, and a
// mutual-exclusion check whose phrases never match passes vacuously — coverage
// in appearance only (rule #167). Both sides are whitespace-normalised anyway,
// so indentation changes cannot quietly break it either.
const RUNG_PHRASES = {
credential: 'Add a credential',
discover: 'Find what you already subscribe to',
adopt: 'not being followed here yet',
unavailable: "couldn't reach",
manual: 'Everything you subscribe to on',
}
const flat = (s) => s.replace(/\s+/g, ' ').trim()
function rungsShown (w) {
const text = flat(w.text())
return Object.entries(RUNG_PHRASES)
.filter(([, phrase]) => text.includes(flat(phrase)))
.map(([name]) => name)
}
describe('FeedEmptyState', () => {
beforeEach(() => {
@@ -58,4 +97,120 @@ describe('FeedEmptyState', () => {
// Decorative: the surrounding prose already carries the meaning.
expect(img.attributes('alt')).toBe('')
})
// --- #387 C6: the discovery rung -----------------------------------------
//
// The loop this closes: a new installer has already told Patreon which
// creators they follow, and making them retype that list is the friction the
// whole milestone is about. These pin that the rung shown matches what is
// actually POSSIBLE — offering discovery before a credential exists, or
// after it has proven unreachable, is a button that cannot work.
it('offers discovery once a credential exists and nothing has synced', () => {
const w = mountWith(EMPTY, { credentials: ['patreon'], sync: NEVER })
expect(w.text()).toContain('Find what you already subscribe to')
expect(w.text()).toContain('patreon')
// Nothing is added for them — the offer/never-auto-add line from C4.
expect(w.text()).toContain('Nothing is added automatically')
})
it('never offers discovery before a credential exists', () => {
// The button would 404 against an unconnected platform. Absence beats a
// dead control on the first screen anyone sees.
const w = mountWith(EMPTY, { sync: NEVER })
expect(w.text()).not.toContain('Find what you already subscribe to')
expect(w.text()).toContain('Add a credential')
})
it('a sweep that has never worked reads as unavailable, not as a retry', () => {
// Rule #164: an install with no outbound network must reach this screen and
// be told plainly. Not a spinner, not a crash, not a button that will fail
// the same way — and the manual path stays open.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }],
})
expect(w.text()).toContain("couldn't reach")
expect(w.text()).toContain('ConnectionError')
expect(w.text()).toContain('Add a source')
expect(w.text()).not.toContain('Find what you already subscribe to')
})
it('a roster that synced once and failed since is NOT unavailable', () => {
// It has a roster, just an ageing one — C4's freshness gate handles that.
// Collapsing the two would hide a usable roster behind an error banner.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: [{
platform: 'patreon',
last_success_at: '2026-09-12T00:00:00Z',
last_error_type: 'PatreonAuthError',
}],
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).not.toContain("couldn't reach")
expect(w.text()).toContain('not being followed here yet')
})
it('counts what there is to adopt, and sends them to the picker', () => {
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }, { id: 2 }] }],
})
expect(w.text()).toContain('2 creators you subscribe to are')
expect(w.text()).toContain('Choose who to follow')
})
it('singularises a lone unmatched creator', () => {
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).toContain('1 creator you subscribe to is')
expect(w.text()).not.toContain('1 creators')
})
it('a fully-tracked roster stops offering discovery', () => {
// Nothing left to find, so the manual path is the honest last rung rather
// than a discovery button that would return an empty list.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }],
})
expect(w.text()).toContain('Everything you subscribe to on')
expect(w.text()).toContain('Add a source')
expect(w.text()).not.toContain('Find what you already subscribe to')
expect(w.text()).not.toContain('not being followed here yet')
})
it('shows exactly one rung in every state', () => {
// The component claims the rungs are mutually exclusive by construction.
// This is that claim, asserted — two on screen at once would be worst
// exactly here, on the first screen a new installer ever sees.
const states = [
[EMPTY, {}],
[EMPTY, { credentials: ['patreon'], sync: NEVER }],
[EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }] }],
[EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }] }],
[EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }] }],
]
for (const [status, world] of states) {
expect(rungsShown(mountWith(status, world))).toHaveLength(1)
}
})
it('an install that is already fetching never sees an on-ramp rung', () => {
// hasSources wins over everything C6 added — someone mid-backfill is not
// onboarding, whatever their roster says.
const w = mountWith({ total_sources: 3 }, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).toContain("See what's running")
expect(rungsShown(w)).toHaveLength(0)
})
})