What you pay for, what Discord drops, and pixiv switched off #251
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user