remove slide effect
This commit is contained in:
@@ -6,7 +6,7 @@ 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, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
@@ -27,15 +27,25 @@ const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
|||||||
const PROGRAMMATIC_SUPPRESS_MS = 300
|
const PROGRAMMATIC_SUPPRESS_MS = 300
|
||||||
let lastY = 0
|
let lastY = 0
|
||||||
let accumulated = 0
|
let accumulated = 0
|
||||||
|
// Cached in onMounted() below instead of re-read via headerRef.offsetHeight
|
||||||
|
// on every scroll event. The header is a fixed size (see onMounted), so
|
||||||
|
// re-measuring it here was pure waste — worse, a DevTools performance trace
|
||||||
|
// showed that offsetHeight read landing right after an unrelated DOM write
|
||||||
|
// elsewhere on the page (e.g. the article view swapping in a new article)
|
||||||
|
// forces the browser into a synchronous, forced-reflow layout pass right
|
||||||
|
// there mid-script, instead of its normal scheduled one. Stays 0 until
|
||||||
|
// onMounted() below sets it (before the scroll listener is attached, so
|
||||||
|
// onScroll() never runs against the unset value) — keep that ordering if
|
||||||
|
// this ever changes.
|
||||||
|
let headerHeight = 0
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
const y = Math.max(0, window.scrollY)
|
const y = Math.max(0, window.scrollY)
|
||||||
const headerH = headerRef.value?.offsetHeight ?? 0
|
|
||||||
|
|
||||||
// Always reveal near the very top, and keep it visible while the menu is
|
// Always reveal near the very top, and keep it visible while the menu is
|
||||||
// open (the dropdown is anchored to the header, so hiding it would slide the
|
// open (the dropdown is anchored to the header, so hiding it would slide the
|
||||||
// open menu off-screen).
|
// open menu off-screen).
|
||||||
if (y <= headerH || menuOpen.value) {
|
if (y <= headerHeight || menuOpen.value) {
|
||||||
hidden.value = false
|
hidden.value = false
|
||||||
accumulated = 0
|
accumulated = 0
|
||||||
lastY = y
|
lastY = y
|
||||||
@@ -71,13 +81,16 @@ function onScrollRaf() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
|
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
|
||||||
// the fixed header isn't hidden behind it at scroll position 0. The header is
|
// the fixed header isn't hidden behind it at scroll position 0. The header is
|
||||||
// a fixed size, so this is measured once on mount and never changes.
|
// a fixed size, so this is measured once on mount and never changes — and
|
||||||
|
// onScroll() reuses this same measurement instead of re-reading it.
|
||||||
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
||||||
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
||||||
|
headerHeight = h
|
||||||
|
|
||||||
lastY = Math.max(0, window.scrollY)
|
lastY = Math.max(0, window.scrollY)
|
||||||
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
refreshUnreadDisplay()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -152,10 +165,11 @@ function handleToggleLayout() {
|
|||||||
class="app-nav__filter"
|
class="app-nav__filter"
|
||||||
:value="feedFilter ?? ''"
|
:value="feedFilter ?? ''"
|
||||||
aria-label="Filter by feed"
|
aria-label="Filter by feed"
|
||||||
|
@focus="refreshUnreadDisplay"
|
||||||
@change="setFeedFilter($event.target.value || null)"
|
@change="setFeedFilter($event.target.value || null)"
|
||||||
>
|
>
|
||||||
<option value="">All feeds</option>
|
<option value="">All feeds</option>
|
||||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
|
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ displayedFeedUnreadCounts[title] ?? 0 }})</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
class="app-nav__hamburger"
|
class="app-nav__hamburger"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import { useFeeds } from '@/composables/useFeeds';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -43,22 +43,6 @@ let touchStartY = null
|
|||||||
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
||||||
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
||||||
|
|
||||||
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
|
|
||||||
// article view uses — set right before the index actually changes, by both
|
|
||||||
// the nav buttons and the swipe handler below, so the two triggers animate
|
|
||||||
// identically.
|
|
||||||
const slideDirection = ref('next')
|
|
||||||
|
|
||||||
function navigateToNextArticle() {
|
|
||||||
slideDirection.value = 'next'
|
|
||||||
nextArticle()
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToPrevArticle() {
|
|
||||||
slideDirection.value = 'prev'
|
|
||||||
prevArticle()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onArticleTouchStart(event) {
|
function onArticleTouchStart(event) {
|
||||||
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
||||||
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
||||||
@@ -83,9 +67,9 @@ function onArticleTouchEnd(event) {
|
|||||||
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
||||||
|
|
||||||
if (deltaX < 0) {
|
if (deltaX < 0) {
|
||||||
navigateToNextArticle()
|
nextArticle()
|
||||||
} else {
|
} else {
|
||||||
navigateToPrevArticle()
|
prevArticle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +173,13 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
|
||||||
<Transition :name="`slide-${slideDirection}`">
|
component forces a synchronous whole-document reflow
|
||||||
<article class="article-feature" :key="currentIndex">
|
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
|
||||||
|
every enter/leave, regardless of the app's own CSS — confirmed (via
|
||||||
|
a DevTools performance trace) to be what was corrupting the header's
|
||||||
|
native <select>/<button> rendering during article paging. -->
|
||||||
|
<article v-else class="article-feature" :key="currentIndex">
|
||||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||||
@@ -201,8 +189,6 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||||
</article>
|
</article>
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="article-nav">
|
<div class="article-nav">
|
||||||
<button
|
<button
|
||||||
@@ -210,14 +196,14 @@ onMounted(async () => {
|
|||||||
class="article-nav__btn"
|
class="article-nav__btn"
|
||||||
:disabled="currentIndex === 0"
|
:disabled="currentIndex === 0"
|
||||||
aria-label="Previous article"
|
aria-label="Previous article"
|
||||||
@click="navigateToPrevArticle"
|
@click="prevArticle"
|
||||||
>↑</button>
|
>↑</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="article-nav__btn"
|
class="article-nav__btn"
|
||||||
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||||
aria-label="Next article"
|
aria-label="Next article"
|
||||||
@click="navigateToNextArticle"
|
@click="nextArticle"
|
||||||
>↓</button>
|
>↓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,46 +367,6 @@ onMounted(async () => {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slide-in/out when paging between articles (swipe or nav buttons). The
|
|
||||||
outgoing article is pulled out of flex flow during its leave transition so
|
|
||||||
the incoming one can take its place immediately, instead of the two
|
|
||||||
stacking and doubling the page height for the transition's duration. */
|
|
||||||
.slide-next-enter-active,
|
|
||||||
.slide-next-leave-active,
|
|
||||||
.slide-prev-enter-active,
|
|
||||||
.slide-prev-leave-active {
|
|
||||||
transition: transform 0.28s ease, opacity 0.28s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-leave-active,
|
|
||||||
.slide-prev-leave-active {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-enter-from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-next-leave-to {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-prev-enter-from {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-prev-leave-to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-feature__source {
|
.article-feature__source {
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
@@ -306,9 +306,18 @@ describe('AppNav', () => {
|
|||||||
// Reset scroll position so each mount's lastY baseline starts at 0.
|
// Reset scroll position so each mount's lastY baseline starts at 0.
|
||||||
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
||||||
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||||
// offsetHeight is 0 in jsdom; give the header a real height so the
|
// getBoundingClientRect() is all-zero in jsdom; give the header a real
|
||||||
// "near the top" guard (scrollY <= headerH) has something to compare to.
|
// height so onMounted()'s one-time measurement (which onScroll() then
|
||||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
|
// reuses instead of re-reading offsetHeight — see AppNav.vue) has
|
||||||
|
// something to compare against for the "near the top" guard. This is a
|
||||||
|
// prototype-wide spy (scoped to this describe block only, restored in
|
||||||
|
// afterEach), so it returns a full DOMRect-shaped object rather than a
|
||||||
|
// partial one — no test here happens to call getBoundingClientRect() on
|
||||||
|
// anything but the header, but a bare `{ height: 50 }` would silently
|
||||||
|
// hand back `undefined` for top/left/width/etc. to any code that did.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -119,6 +119,32 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
|
||||||
|
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
|
||||||
|
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
|
||||||
|
// computed so paging through article view doesn't force it to repaint.
|
||||||
|
// List-view scrolling is a different code path (handleIntersection) and
|
||||||
|
// should keep it live regardless.
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 301, feedTitle: 'Feed A', read: false },
|
||||||
|
{ id: 302, feedTitle: 'Feed A', read: false },
|
||||||
|
]
|
||||||
|
feeds.value = [...allItems.value]
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
|
||||||
|
|
||||||
|
setInitialLoad(true)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
{ id: 201, title: 'First' },
|
{ id: 201, title: 'First' },
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ const unreadCount = computed(() => {
|
|||||||
: allItems.value
|
: allItems.value
|
||||||
return items.filter(i => !i.read).length
|
return items.filter(i => !i.read).length
|
||||||
})
|
})
|
||||||
|
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||||
|
// for the filter <select>'s per-feed option counts, instead of binding
|
||||||
|
// feedUnreadCounts directly — by design, this stays frozen while paging
|
||||||
|
// through article view (markCurrentArticleRead marks one article read per
|
||||||
|
// page turn, which would otherwise recompute it live on every single swipe).
|
||||||
|
// The header's own unread badge stays bound to the live unreadCount (it
|
||||||
|
// should update in real time regardless of view). Everywhere else that marks
|
||||||
|
// articles read — list-view scrolling (handleIntersection), opening/changing
|
||||||
|
// the filter, syncing, mark-all-read, leaving article view — refreshes this
|
||||||
|
// snapshot immediately, so it only ever looks stale during article-view
|
||||||
|
// paging itself.
|
||||||
|
const displayedFeedUnreadCounts = ref({})
|
||||||
|
function refreshUnreadDisplay() {
|
||||||
|
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||||
|
}
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const showModal = ref(false)
|
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
|
||||||
@@ -242,6 +257,7 @@ async function setFeedFilter(title) {
|
|||||||
feedFilter.value = title // null for "All feeds"
|
feedFilter.value = title // null for "All feeds"
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
applyFilter()
|
applyFilter()
|
||||||
|
refreshUnreadDisplay()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setupIntersectionObserver()
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
@@ -259,6 +275,7 @@ const fetchData = async () => {
|
|||||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
allItems.value = items;
|
allItems.value = items;
|
||||||
applyFilter();
|
applyFilter();
|
||||||
|
refreshUnreadDisplay();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -346,6 +363,7 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
// Mirror into the master so cleared filters don't resurrect read articles.
|
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
|
||||||
for (const feed of readFeeds) {
|
for (const feed of readFeeds) {
|
||||||
markRead(feed.id)
|
markRead(feed.id)
|
||||||
@@ -392,6 +410,7 @@ async function markAllRead() {
|
|||||||
// is active) — drop exactly those from the master too.
|
// is active) — drop exactly those from the master too.
|
||||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
|
refreshUnreadDisplay()
|
||||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||||
await Promise.all(ids.map(id => markRead(id)))
|
await Promise.all(ids.map(id => markRead(id)))
|
||||||
showMessageForXSeconds('All articles marked as read.', 5)
|
showMessageForXSeconds('All articles marked as read.', 5)
|
||||||
@@ -418,6 +437,7 @@ async function leaveArticleView() {
|
|||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
localStorage.setItem('viewMode', viewMode.value)
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
|
refreshUnreadDisplay()
|
||||||
// The v-if on the list container tears down and recreates all .observe DOM
|
// The v-if on the list container tears down and recreates all .observe DOM
|
||||||
// nodes when switching views, so the intersection observer must be
|
// nodes when switching views, so the intersection observer must be
|
||||||
// re-pointed at the new elements after Vue has finished rendering.
|
// re-pointed at the new elements after Vue has finished rendering.
|
||||||
@@ -479,6 +499,8 @@ export function useFeeds() {
|
|||||||
feedTitles,
|
feedTitles,
|
||||||
feedUnreadCounts,
|
feedUnreadCounts,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
displayedFeedUnreadCounts,
|
||||||
|
refreshUnreadDisplay,
|
||||||
setFeedFilter,
|
setFeedFilter,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
|
|||||||
Reference in New Issue
Block a user