Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584ac4f60 | ||
|
|
9c042411fb | ||
|
|
4d9e899519 | ||
|
|
c290513ed1 | ||
|
|
41a17eba6d | ||
|
|
169c294e62 | ||
|
|
e2df60d112 |
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import Modal from './modal/AddUrl.vue'
|
||||||
|
|
||||||
|
const showModal = ref(false)
|
||||||
const feeds = ref([])
|
const feeds = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin">
|
<div class="admin">
|
||||||
<h1 class="admin__heading">Admin</h1>
|
<h1 class="admin__heading">Admin</h1>
|
||||||
|
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
|
||||||
<p v-if="error" class="admin__error">{{ error }}</p>
|
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||||
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||||
<ul v-else class="admin__list">
|
<ul v-else class="admin__list">
|
||||||
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
|
|||||||
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
|
||||||
|
<template #header>
|
||||||
|
<h3>Add RSS Feed</h3>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin__add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__add:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.admin__error,
|
.admin__error,
|
||||||
.admin__empty {
|
.admin__empty {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||||
import Modal from './modal/AddUrl.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
@@ -147,11 +146,6 @@ function handleMarkAllRead() {
|
|||||||
closeMenu()
|
closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddModal() {
|
|
||||||
showModal.value = true
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleViewMode() {
|
function handleToggleViewMode() {
|
||||||
toggleViewMode()
|
toggleViewMode()
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -167,6 +161,11 @@ function handleToggleLayout() {
|
|||||||
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<div class="app-nav__wrapper">
|
<div class="app-nav__wrapper">
|
||||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
|
<span
|
||||||
|
class="app-nav__sync-spinner"
|
||||||
|
:class="{ 'app-nav__sync-spinner--active': isSyncing }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
<select
|
<select
|
||||||
v-if="onFeedsPage && feedTitles.length"
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
class="app-nav__filter"
|
class="app-nav__filter"
|
||||||
@@ -212,20 +211,11 @@ function handleToggleLayout() {
|
|||||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||||
</template>
|
</template>
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
|
||||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<Modal :show="showModal" @close="showModal = false">
|
|
||||||
<template #header>
|
|
||||||
<h3>Add RSS Feed</h3>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</Teleport>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -266,6 +256,34 @@ function handleToggleLayout() {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner--active {
|
||||||
|
visibility: visible;
|
||||||
|
animation: app-nav-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-nav-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav__sync-spinner--active {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__filter {
|
.app-nav__filter {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-width: clamp(120px, 40vw, 220px);
|
max-width: clamp(120px, 40vw, 220px);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const {
|
|||||||
disconnectObserver,
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
|
markCurrentArticleRead,
|
||||||
} = useFeeds()
|
} = useFeeds()
|
||||||
|
|
||||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||||
@@ -122,6 +123,11 @@ onBeforeUnmount(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
// viewMode persists across reloads (see useFeeds.js) — if we're booting
|
||||||
|
// straight into article view, the article now on screen needs the same
|
||||||
|
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
|
||||||
|
// give it when reached by in-session navigation.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
sync(true)
|
sync(true)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
setInitialLoad(true)
|
setInitialLoad(true)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AdminFeeds from '../AdminFeeds.vue'
|
||||||
|
import AddUrl from '../modal/AddUrl.vue'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
describe('AdminFeeds', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each mount renders the AddUrl modal via <Teleport to="body">, which
|
||||||
|
// survives outside the wrapper's own element — unmount every mounted
|
||||||
|
// instance so a leftover doesn't bleed into a later test's document.body.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountAdmin() {
|
||||||
|
const wrapper = mount(AdminFeeds)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches and renders the feed list on mount', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('Feed A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty state when there are no feeds', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error message when the feed list fails to load', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('Network Error'))
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to load feeds.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a feed after confirmation', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
axios.delete.mockResolvedValueOnce({ status: 204 })
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__delete').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the add-feed modal from the Add RSS button', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__add').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the feed list when the modal reports a feed was added', async () => {
|
||||||
|
axios.get
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
await wrapper.findComponent(AddUrl).vm.$emit('added')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.text()).toContain('Feed B')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,7 +25,7 @@ describe('AppNav', () => {
|
|||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
allItems.value = []
|
allItems.value = []
|
||||||
feedFilter.value = null
|
feedFilter.value = null
|
||||||
@@ -34,7 +34,6 @@ describe('AppNav', () => {
|
|||||||
lastProgrammaticScroll.value = 0
|
lastProgrammaticScroll.value = 0
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
layout.value = 'list'
|
layout.value = 'list'
|
||||||
@@ -130,16 +129,6 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the add-feed modal from the menu', async () => {
|
|
||||||
const wrapper = await mountWithMenuOpen()
|
|
||||||
const { showModal } = useFeeds()
|
|
||||||
|
|
||||||
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
|
|
||||||
await addButton.trigger('click')
|
|
||||||
|
|
||||||
expect(showModal.value).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches the view mode from the menu and closes it', async () => {
|
it('switches the view mode from the menu and closes it', async () => {
|
||||||
const wrapper = await mountWithMenuOpen()
|
const wrapper = await mountWithMenuOpen()
|
||||||
const { viewMode } = useFeeds()
|
const { viewMode } = useFeeds()
|
||||||
@@ -321,6 +310,48 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('sync spinner', () => {
|
||||||
|
it('always renders the spinner, inactive, when idle', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const spinner = wrapper.find('.app-nav__sync-spinner')
|
||||||
|
expect(spinner.exists()).toBe(true)
|
||||||
|
expect(spinner.classes()).not.toContain('app-nav__sync-spinner--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates while a sync triggered through the composable is in flight, and deactivates once it resolves', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').classes()).toContain('app-nav__sync-spinner--active')
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').classes()).not.toContain('app-nav__sync-spinner--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders even on a page where the feed filter itself does not render', async () => {
|
||||||
|
await router.push('/login')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('scroll-driven show/hide', () => {
|
describe('scroll-driven show/hide', () => {
|
||||||
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||||
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||||
|
|||||||
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
|
|||||||
|
|
||||||
// useFeeds() returns module-level singleton refs shared across the whole
|
// useFeeds() returns module-level singleton refs shared across the whole
|
||||||
// app (and this spec file) — reset them so state doesn't leak between tests.
|
// app (and this spec file) — reset them so state doesn't leak between tests.
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
layout.value = 'list'
|
layout.value = 'list'
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import '@/assets/modal.css';
|
import '@/assets/modal.css';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
show: Boolean
|
show: Boolean
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['close', 'added'])
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
@@ -30,6 +31,7 @@ async function save() {
|
|||||||
|
|
||||||
console.log(response)
|
console.log(response)
|
||||||
output.value = 'saved successfully'
|
output.value = 'saved successfully'
|
||||||
|
emit('added')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message)
|
console.error(error.message)
|
||||||
output.value = error.message
|
output.value = error.message
|
||||||
@@ -57,7 +59,7 @@ async function save() {
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
expect(wrapper.text()).toContain('saved successfully')
|
expect(wrapper.text()).toContain('saved successfully')
|
||||||
|
expect(wrapper.emitted('added')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces the error message when the request fails', async () => {
|
it('surfaces the error message when the request fails', async () => {
|
||||||
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Network Error')
|
expect(wrapper.text()).toContain('Network Error')
|
||||||
|
expect(wrapper.emitted('added')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
|
||||||
|
const wrapper = mount(AddUrl, { props: { show: true } })
|
||||||
|
await wrapper.find('#url').setValue('https://example.test/feed.xml')
|
||||||
|
await wrapper.find('#title').setValue('Example feed')
|
||||||
|
|
||||||
|
await wrapper.find('.modal-default-button').trigger('click')
|
||||||
|
|
||||||
|
expect(axios.post).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, setupIntersectionObserver } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver, isSyncing } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -24,7 +24,7 @@ describe('useFeeds', () => {
|
|||||||
feedFilter.value = null
|
feedFilter.value = null
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
viewMode.value = 'list'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches and flattens articles for the current user', async () => {
|
it('fetches and flattens articles for the current user', async () => {
|
||||||
@@ -177,6 +177,279 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('mark-as-read suppressed while syncing', () => {
|
||||||
|
it('queues list-view scroll marking while a reload (fetchData) is in flight, and flushes it automatically once it finishes', async () => {
|
||||||
|
feeds.value = [{ id: 701, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
// No .observe nodes exist here, so this deterministically clears
|
||||||
|
// skipNextObservation rather than relying on whatever an earlier test
|
||||||
|
// left it as.
|
||||||
|
setupIntersectionObserver()
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
// Queued, not dropped — untouched while the reload is in flight, unlike
|
||||||
|
// a normal scroll-past which removes it immediately.
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([701])
|
||||||
|
|
||||||
|
// The refetch's response still lists it (its read never committed) —
|
||||||
|
// the flush that follows must find and remove it by id.
|
||||||
|
resolveGet({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 701, title: 'First', content: '', url: 'https://example.test/701', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Flushed automatically once the reload finished — no second scroll needed.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/701', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues list-view scroll marking for the whole duration of sync(), and flushes it once sync finishes', async () => {
|
||||||
|
feeds.value = [{ id: 702, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
setupIntersectionObserver() // deterministically clears skipNextObservation — see the previous test
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
// Still mid network round-trip to /api/v1/article/sync — fetchData()
|
||||||
|
// hasn't even started yet, but scroll-marking must already be queued.
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([702])
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/702', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues article-view display marking while a reload is in flight, and flushes it in place once the reload finishes', async () => {
|
||||||
|
viewMode.value = 'article' // still on screen when the flush runs — must not be removed out from under it
|
||||||
|
feeds.value = [{ id: 703, title: 'First' }]
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
markCurrentArticleRead()
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value[0].read).toBeFalsy()
|
||||||
|
|
||||||
|
// The refetch's response still lists it (its read never committed), same
|
||||||
|
// id, now at whatever index it re-sorts to — the flush must find it by
|
||||||
|
// id, not by the currentIndex it was queued under.
|
||||||
|
resolveGet({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 703, title: 'First', content: '', url: 'https://example.test/703', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Flushed automatically once the reload finished — no second call needed.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/703', null, expect.anything())
|
||||||
|
// Still in article view — marked in place, not removed (removing it here
|
||||||
|
// would yank whatever's on screen out from under currentIndex; the same
|
||||||
|
// invariant markCurrentArticleRead() already keeps while paging).
|
||||||
|
expect(feeds.value.find(f => f.id === 703)).toMatchObject({ read: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a queued article-view read once flushed, if the user already left article view before the reload finished', async () => {
|
||||||
|
feeds.value = [{ id: 707, title: 'First' }]
|
||||||
|
viewMode.value = 'article'
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
markCurrentArticleRead() // queued: isSyncing is true
|
||||||
|
// The user backs out to list view before the reload finishes —
|
||||||
|
// leaveArticleView()'s dropReadArticles() runs here, but this article
|
||||||
|
// isn't flagged read yet (it's only queued), so it survives that pass.
|
||||||
|
viewMode.value = 'list'
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([707])
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Now in list view with no further transition to rely on — the flush
|
||||||
|
// must sweep it out itself instead of leaving it stuck looking unread.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/707', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues every article paged past during a sync, flushing all of them once it ends', async () => {
|
||||||
|
feeds.value = [
|
||||||
|
{ id: 704, title: 'First' },
|
||||||
|
{ id: 705, title: 'Second' },
|
||||||
|
]
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
markCurrentArticleRead() // pages onto article 704
|
||||||
|
currentIndex.value = 1
|
||||||
|
markCurrentArticleRead() // pages onto article 705
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/704', null, expect.anything())
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/705', null, expect.anything())
|
||||||
|
|
||||||
|
currentIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reopen the mark-as-read window when an independent fetchData() resolves while a sync() is still in flight', async () => {
|
||||||
|
feeds.value = [{ id: 706, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
setupIntersectionObserver() // deterministically clears skipNextObservation — see above
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
// sync() starts and is left mid network round-trip.
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
// An independent fetchData() call (e.g. a separate reload) runs to
|
||||||
|
// completion while sync()'s POST is still pending.
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// sync() itself hasn't finished — scroll-marking must still be
|
||||||
|
// suppressed, not reopened by the independent call settling first.
|
||||||
|
feeds.value = [{ id: 706, title: 'First' }]
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
// Only now, with sync() truly finished, does the queued entry flush.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/706', null, expect.anything())
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isSyncing', () => {
|
||||||
|
// Lets a just-resolved deferred promise's `.then` continuations (e.g.
|
||||||
|
// sync()'s `await fetchData()`) actually run before the next assertion,
|
||||||
|
// without pulling in fake timers (see the CLAUDE.md Vitest gotcha).
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is false initially, true while a fetchData() call is in flight, and false again once it resolves', async () => {
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays true for the whole duration of sync(), including its trailing fetchData(), and only clears once sync() fully settles', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// The sync POST settles, handing off into sync()'s own trailing
|
||||||
|
// fetchData() — isSyncing must not flicker false in between.
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalled()
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves isSyncing true while an independent fetchData() completes during a still in-flight sync(), clearing only once both finish', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// An independent fetchData() call runs to completion while sync()'s
|
||||||
|
// POST is still pending.
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// sync() itself hasn't finished yet.
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected fetchData()', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await fetchData() // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected sync()', async () => {
|
||||||
|
axios.post.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await sync(true) // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('markRead resilience', () => {
|
describe('markRead resilience', () => {
|
||||||
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
||||||
// sync via this comment: if that backoff changes, update this too — too
|
// sync via this comment: if that backoff changes, update this too — too
|
||||||
@@ -423,6 +696,60 @@ describe('useFeeds', () => {
|
|||||||
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
|
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not resurrect an article-view read-in-place article when re-selecting its feed', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
|
// Article view marks the displayed article read in place (feed.read =
|
||||||
|
// true) without removing it, so currentIndex stays valid while paging —
|
||||||
|
// see markCurrentArticleRead. It's still in the list at this point.
|
||||||
|
markCurrentArticleRead()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
|
// Switching to another feed and back must not resurrect it as if it
|
||||||
|
// were still unread.
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the newly displayed article read when the filter is switched while in article view', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
viewMode.value = 'article'
|
||||||
|
|
||||||
|
// Switching into Feed B while in article view immediately displays its
|
||||||
|
// one article's full content — that counts as "viewed", so it's marked
|
||||||
|
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
||||||
|
expect(feeds.value[0].read).toBe(true)
|
||||||
|
|
||||||
|
// It doesn't resurface either, once dropped on the next switch.
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark the newly displayed article read when the filter is switched in list view', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// List view only shows titles — switching filters there must not mark
|
||||||
|
// anything read; that's still driven solely by scroll (handleIntersection).
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value[0].read).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
|
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
|
||||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
axios.put.mockResolvedValue({ status: 200 })
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ describe('viewMode boot persistence', () => {
|
|||||||
expect(wrapper.find('.article-single').exists()).toBe(true)
|
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||||
expect(wrapper.find('#article').exists()).toBe(false)
|
expect(wrapper.find('#article').exists()).toBe(false)
|
||||||
|
|
||||||
|
// The article shown right after reload must be marked read immediately —
|
||||||
|
// not only once the user pages forward/back at least once.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
|
||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ function refreshUnreadDisplay() {
|
|||||||
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||||
}
|
}
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const showModal = ref(false)
|
|
||||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||||
const currentIndex = ref(0)
|
const currentIndex = ref(0)
|
||||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||||
@@ -76,6 +75,63 @@ let initialLoad = false
|
|||||||
// genuine scroll-driven exits mark read after that.
|
// genuine scroll-driven exits mark read after that.
|
||||||
let skipNextObservation = false
|
let skipNextObservation = false
|
||||||
|
|
||||||
|
// Count of fetchData()/sync() calls currently in flight — a refcount rather
|
||||||
|
// than a plain boolean for the same reason as pendingReadCounts below: sync()
|
||||||
|
// awaits its own trailing fetchData(), so one call nests inside the other, and
|
||||||
|
// fetchData() can also be invoked independently (RssFeeds.vue's boot reload)
|
||||||
|
// while a sync() elsewhere is still mid network round-trip. A bare boolean
|
||||||
|
// would have whichever call finishes first clear it out from under the other
|
||||||
|
// still-running one. Both rebuild allItems/feeds from the server, so the
|
||||||
|
// passive/paging mark-as-read paths — handleIntersection's scroll-marking and
|
||||||
|
// markCurrentArticleRead's display-marking — must not fire while any is in
|
||||||
|
// flight. markAllRead is a deliberate, already-confirmed action and is left
|
||||||
|
// ungated.
|
||||||
|
const activeSyncCount = ref(0)
|
||||||
|
function beginSync() {
|
||||||
|
activeSyncCount.value += 1
|
||||||
|
}
|
||||||
|
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
|
||||||
|
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
|
||||||
|
// has finished, rather than left unread forever. Flushing by id (not by
|
||||||
|
// re-deriving an index) sidesteps fetchData() having replaced
|
||||||
|
// feeds.value/allItems.value wholesale in the meantime — the id is still
|
||||||
|
// valid, wherever the item now sits.
|
||||||
|
const pendingSuppressedReadIds = new Set()
|
||||||
|
|
||||||
|
function endSync() {
|
||||||
|
activeSyncCount.value -= 1
|
||||||
|
if (activeSyncCount.value === 0) {
|
||||||
|
flushPendingSuppressedReads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSyncing = computed(() => activeSyncCount.value > 0)
|
||||||
|
|
||||||
|
function flushPendingSuppressedReads() {
|
||||||
|
if (pendingSuppressedReadIds.size === 0) return
|
||||||
|
const ids = [...pendingSuppressedReadIds]
|
||||||
|
pendingSuppressedReadIds.clear()
|
||||||
|
for (const id of ids) {
|
||||||
|
// feeds.value is always a filter()/slice() projection of allItems.value
|
||||||
|
// (see applyFilter), so every item in it is the same object reference as
|
||||||
|
// one already in allItems.value — one lookup is enough.
|
||||||
|
const feed = allItems.value.find(f => f.id === id)
|
||||||
|
if (feed) feed.read = true
|
||||||
|
markRead(id)
|
||||||
|
}
|
||||||
|
// A still-open article view marks in place and relies on dropReadArticles()
|
||||||
|
// running at the next leaveArticleView()/setFeedFilter() to clean up — same
|
||||||
|
// invariant markCurrentArticleRead() already keeps while paging, and
|
||||||
|
// removing one here could shift currentIndex out from under whatever's on
|
||||||
|
// screen. List view has no such upcoming transition to rely on (the user
|
||||||
|
// may just keep scrolling), so sweep these out now: this is exactly what a
|
||||||
|
// scroll-triggered mark would already have done immediately, had a sync not
|
||||||
|
// been in flight.
|
||||||
|
if (viewMode.value !== 'article') {
|
||||||
|
dropReadArticles()
|
||||||
|
}
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||||
// mutation that moves the page without user intent — currently the list-view
|
// mutation that moves the page without user intent — currently the list-view
|
||||||
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||||
@@ -308,6 +364,22 @@ function applyFilter() {
|
|||||||
: allItems.value.slice()
|
: allItems.value.slice()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Article view marks an article read in place (feed.read = true) without
|
||||||
|
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
|
||||||
|
// paging within that same list. Anything switches the list out from under
|
||||||
|
// that index (leaving article view, or re-filtering while still in it) has to
|
||||||
|
// drop those read-in-place articles from the master here, or they resurface:
|
||||||
|
// re-selecting their feed would otherwise re-project them from allItems as if
|
||||||
|
// still unread.
|
||||||
|
function dropReadArticles() {
|
||||||
|
// The feeds.value filter here is redundant for the setFeedFilter() caller
|
||||||
|
// (applyFilter() overwrites feeds.value again right after) but is what
|
||||||
|
// leaveArticleView() actually needs — kept shared rather than split into two
|
||||||
|
// near-identical branches.
|
||||||
|
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||||
|
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||||
|
}
|
||||||
|
|
||||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||||
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||||
@@ -316,17 +388,25 @@ function applyFilter() {
|
|||||||
async function setFeedFilter(title) {
|
async function setFeedFilter(title) {
|
||||||
disconnectObserver()
|
disconnectObserver()
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
|
dropReadArticles()
|
||||||
feedFilter.value = title // null for "All feeds"
|
feedFilter.value = title // null for "All feeds"
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
applyFilter()
|
applyFilter()
|
||||||
|
// In article view the item at currentIndex is fully displayed, not just
|
||||||
|
// listed — unlike a plain currentIndex reset from paging, this one really is
|
||||||
|
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
|
||||||
|
// prevArticle() do. List view only shows titles here; its own read-marking
|
||||||
|
// happens via scroll (handleIntersection), so this must not fire there.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
refreshUnreadDisplay()
|
refreshUnreadDisplay()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setupIntersectionObserver()
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const user_id = localStorage.getItem("user-id")
|
|
||||||
try {
|
try {
|
||||||
|
beginSync()
|
||||||
|
const user_id = localStorage.getItem("user-id")
|
||||||
// Snapshot ids pending *before* the GET goes out, not just when its
|
// Snapshot ids pending *before* the GET goes out, not just when its
|
||||||
// response comes back. A markRead() PUT that commits while this GET is
|
// response comes back. A markRead() PUT that commits while this GET is
|
||||||
// in flight is invisible to the check below (its id has already been
|
// in flight is invisible to the check below (its id has already been
|
||||||
@@ -356,10 +436,13 @@ const fetchData = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error)
|
console.error('Error fetching data:', error)
|
||||||
showMessageForXSeconds(error, 5)
|
showMessageForXSeconds(error, 5)
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sync(silent = false) {
|
async function sync(silent = false) {
|
||||||
|
beginSync()
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/v1/article/sync', {
|
const response = await axios.post('/api/v1/article/sync', {
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
@@ -368,12 +451,14 @@ async function sync(silent = false) {
|
|||||||
if (response.status == 200 && !silent) {
|
if (response.status == 200 && !silent) {
|
||||||
showMessageForXSeconds('Sync successful.', 5)
|
showMessageForXSeconds('Sync successful.', 5)
|
||||||
}
|
}
|
||||||
fetchData();
|
await fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sync', error)
|
console.error('Error sync', error)
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showMessageForXSeconds(error, 5)
|
showMessageForXSeconds(error, 5)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +507,17 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
|
|
||||||
if (readFeeds.length === 0) return
|
if (readFeeds.length === 0) return
|
||||||
|
|
||||||
|
if (isSyncing.value) {
|
||||||
|
// Don't mutate a list that's mid-reload — queue these ids instead of
|
||||||
|
// dropping them; flushPendingSuppressedReads() marks (and removes) them
|
||||||
|
// once every in-flight fetchData()/sync() has finished. Nothing here
|
||||||
|
// touched the DOM, so there's no observer disconnect/reconnect to do.
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Disconnect before the DOM mutation. In card layout the cards are short
|
// Disconnect before the DOM mutation. In card layout the cards are short
|
||||||
// enough that the shift caused by removing one can push the next card above
|
// enough that the shift caused by removing one can push the next card above
|
||||||
// the header, which the observer would immediately treat as another read —
|
// the header, which the observer would immediately treat as another read —
|
||||||
@@ -502,22 +598,26 @@ async function markAllRead() {
|
|||||||
|
|
||||||
function markCurrentArticleRead() {
|
function markCurrentArticleRead() {
|
||||||
const feed = feeds.value[currentIndex.value]
|
const feed = feeds.value[currentIndex.value]
|
||||||
|
if (!feed) return
|
||||||
|
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
|
||||||
|
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
|
||||||
|
// marks it once every in-flight fetchData()/sync() has finished.
|
||||||
|
if (isSyncing.value) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Marking read here (rather than via removeFeed, as the scroll-based list
|
// Marking read here (rather than via removeFeed, as the scroll-based list
|
||||||
// view does) keeps the array stable so currentIndex stays valid while paging.
|
// view does) keeps the array stable so currentIndex stays valid while paging.
|
||||||
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
||||||
if (feed) {
|
|
||||||
feed.read = true
|
feed.read = true
|
||||||
markRead(feed.id)
|
markRead(feed.id)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveArticleView() {
|
async function leaveArticleView() {
|
||||||
// Articles paged past in article view were marked read but deliberately kept
|
// Articles paged past in article view were marked read but deliberately kept
|
||||||
// in place so currentIndex stayed valid — drop them now so they don't keep
|
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||||
// showing up in the list view.
|
// showing up in the list view.
|
||||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
dropReadArticles()
|
||||||
// Shared references — the paged-past objects carry .read on the master too.
|
|
||||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
localStorage.setItem('viewMode', viewMode.value)
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
@@ -588,11 +688,11 @@ export function useFeeds() {
|
|||||||
setFeedFilter,
|
setFeedFilter,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
showModal,
|
|
||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
toggleViewMode,
|
toggleViewMode,
|
||||||
leaveArticleView,
|
leaveArticleView,
|
||||||
|
markCurrentArticleRead,
|
||||||
layout,
|
layout,
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
nextArticle,
|
nextArticle,
|
||||||
@@ -609,5 +709,6 @@ export function useFeeds() {
|
|||||||
handleIntersection,
|
handleIntersection,
|
||||||
lastProgrammaticScroll,
|
lastProgrammaticScroll,
|
||||||
markProgrammaticScroll,
|
markProgrammaticScroll,
|
||||||
|
isSyncing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user