Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f5f0f722 | ||
|
|
43269bf1d7 | ||
|
|
5ed780baa3 | ||
|
|
8f7ddb77c4 | ||
|
|
e51bce4244 | ||
|
|
f31fccdd16 | ||
|
|
a7cfd267c6 | ||
|
|
c584ac4f60 | ||
|
|
9c042411fb | ||
|
|
4d9e899519 | ||
|
|
c290513ed1 | ||
|
|
41a17eba6d | ||
|
|
169c294e62 | ||
|
|
e2df60d112 | ||
|
|
5f816bb0f5 | ||
|
|
67b970df9b | ||
|
|
6c8b4e9e32 | ||
|
|
e2fb32f898 | ||
|
|
a2c947f870 | ||
|
|
abb6002078 | ||
|
|
f447a9a163 | ||
|
|
a26f31a150 | ||
|
|
c911258578 | ||
|
|
d52fa90b6b |
Generated
+595
-865
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -10,7 +10,12 @@ anyhow = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rss = { version = "2.0.13" }
|
||||
actix-web = "4.13"
|
||||
# `http2` is left out: the server binds plain HTTP/1.1 (no TLS/h2c), and the
|
||||
# feature pulls in the unmaintained h2 0.3 line (RUSTSEC-2026-0258).
|
||||
actix-web = { version = "4.15", default-features = false, features = [
|
||||
"macros", "compress-brotli", "compress-gzip", "compress-zstd",
|
||||
"cookies", "unicode", "compat", "ws",
|
||||
] }
|
||||
actix-rt = "2.10"
|
||||
futures = "0.3.31"
|
||||
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
|
||||
@@ -18,7 +23,7 @@ serde_derive = "1.0.228"
|
||||
actix-service = "2.0.3"
|
||||
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||
diesel_migrations = "2.3"
|
||||
dotenv = "0.15.0"
|
||||
dotenvy = "0.15"
|
||||
bcrypt = "0.19"
|
||||
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||
jwt = "0.16.0"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- This file should undo anything in `up.sql`
|
||||
ALTER TABLE feed_item
|
||||
DROP CONSTRAINT feed_item_feed_id_url_unique;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Your SQL goes here
|
||||
|
||||
-- Concurrent syncs for the same feed could previously race past the
|
||||
-- application-level "does this article already exist" check and both
|
||||
-- insert, producing duplicate feed items. Before enforcing uniqueness,
|
||||
-- collapse any duplicates that already exist, keyed on the article's link
|
||||
-- (rather than its title) since that's the stable identity RSS items are
|
||||
-- built around — some feeds edit a headline after publishing while keeping
|
||||
-- the same link, which title-based matching would treat as a new article.
|
||||
|
||||
-- If any duplicate in a group was already read, propagate that onto the
|
||||
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
|
||||
-- already-read article look unread again.
|
||||
UPDATE feed_item AS keep
|
||||
SET read = true
|
||||
FROM feed_item AS dupe
|
||||
WHERE keep.feed_id = dupe.feed_id
|
||||
AND keep.url = dupe.url
|
||||
AND keep.id <> dupe.id
|
||||
AND keep.id = (
|
||||
SELECT MIN(candidate.id)
|
||||
FROM feed_item AS candidate
|
||||
WHERE candidate.feed_id = dupe.feed_id
|
||||
AND candidate.url = dupe.url
|
||||
)
|
||||
AND dupe.read = true;
|
||||
|
||||
-- Keep the oldest (lowest id) row of each duplicate set.
|
||||
DELETE FROM feed_item a
|
||||
USING feed_item b
|
||||
WHERE a.feed_id = b.feed_id
|
||||
AND a.url = b.url
|
||||
AND a.id > b.id;
|
||||
|
||||
ALTER TABLE feed_item
|
||||
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
|
||||
+1
-1
@@ -6,7 +6,7 @@ use std::env;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use chrono::{Duration, Utc};
|
||||
use dotenv::dotenv;
|
||||
use dotenvy::dotenv;
|
||||
use hmac::{Hmac, Mac};
|
||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use dotenv::dotenv;
|
||||
use dotenvy::dotenv;
|
||||
use std::env;
|
||||
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
+1
-2
@@ -1,10 +1,9 @@
|
||||
extern crate diesel;
|
||||
extern crate dotenv;
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_service::Service;
|
||||
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||
use dotenv::dotenv;
|
||||
use dotenvy::dotenv;
|
||||
use futures::future::{ok, Either};
|
||||
use std::env;
|
||||
mod auth;
|
||||
|
||||
+76
-19
@@ -4,8 +4,7 @@ use crate::error::AppError;
|
||||
use crate::json_serialization::user::JsonUser;
|
||||
use crate::models::feed::rss_feed::Feed;
|
||||
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||
use crate::schema::feed_item::{feed_id, title};
|
||||
use crate::schema::feed_item::{feed_id, url};
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
schema::{
|
||||
@@ -143,24 +142,29 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
|
||||
}
|
||||
}
|
||||
|
||||
let existing_item: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
.filter(title.eq(&item_title))
|
||||
.load(connection)?;
|
||||
let new_feed_item = NewFeedItem::new(
|
||||
feed.id,
|
||||
content.clone(),
|
||||
item_title.clone(),
|
||||
item.link.expect("checked above"),
|
||||
Some(time),
|
||||
);
|
||||
|
||||
if existing_item.is_empty() {
|
||||
let new_feed_item = NewFeedItem::new(
|
||||
feed.id,
|
||||
content.clone(),
|
||||
item_title.clone(),
|
||||
item.link.expect("checked above"),
|
||||
Some(time),
|
||||
);
|
||||
let insert_result = diesel::insert_into(feed_item::table)
|
||||
.values(&new_feed_item)
|
||||
.execute(connection);
|
||||
// `on_conflict` on the (feed_id, url) unique constraint makes this
|
||||
// insert idempotent, so two syncs for the same feed running concurrently
|
||||
// (e.g. a sync button press racing a page-reload sync) can't both pass a
|
||||
// check-then-insert race and create duplicate items. Keying on the
|
||||
// article's link rather than its title also handles feeds that edit a
|
||||
// headline after publishing while keeping the same link — the link is
|
||||
// the stable identity.
|
||||
let inserted_rows = diesel::insert_into(feed_item::table)
|
||||
.values(&new_feed_item)
|
||||
.on_conflict((feed_id, url))
|
||||
.do_nothing()
|
||||
.execute(connection)?;
|
||||
|
||||
log::info!("Insert Result: {:?}", insert_result);
|
||||
if inserted_rows > 0 {
|
||||
log::info!("Inserted item: {}", item_title);
|
||||
} else {
|
||||
log::info!("Item {} already exists.", item_title);
|
||||
}
|
||||
@@ -210,10 +214,11 @@ pub async fn sync(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::feed::new_feed::NewFeed;
|
||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||
use crate::models::user::new_user::NewUser;
|
||||
use crate::models::user::rss_user::User;
|
||||
use crate::schema::users;
|
||||
use crate::test_helpers::unique_suffix;
|
||||
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
|
||||
use chrono::Duration;
|
||||
|
||||
use super::*;
|
||||
@@ -440,6 +445,58 @@ mod tests {
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
|
||||
let mut connection = establish_connection();
|
||||
let suffix = unique_suffix();
|
||||
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
let feed = insert_feed(&mut connection, user.id);
|
||||
|
||||
let mut item = Item::default();
|
||||
item.set_title(Some(format!("Race test article {suffix}")));
|
||||
item.set_link(Some(format!("https://example.test/race/{suffix}")));
|
||||
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||
|
||||
// Simulate two concurrent syncs for the same feed (e.g. a sync
|
||||
// button press racing a page-reload sync) — each on its own
|
||||
// connection/thread, released together so they genuinely overlap —
|
||||
// both inserting the same item.
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let handles: Vec<_> = (0..2)
|
||||
.map(|_| {
|
||||
let feed = feed.clone();
|
||||
let item = item.clone();
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut connection = establish_connection();
|
||||
barrier.wait();
|
||||
create_feed_item(item, &feed, &mut connection)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
handle.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
let items: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
.load(&mut connection)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
1,
|
||||
items.len(),
|
||||
"concurrent syncs must not create duplicate feed items"
|
||||
);
|
||||
|
||||
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
delete_feed(&mut connection, feed.id);
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn create_feed_item_strips_onerror_from_feed_image() {
|
||||
let mut connection = establish_connection();
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<meta name="theme-color" content="#1a8f5e">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RSS-Reader</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
||||
Generated
+1127
-1897
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -26,6 +26,6 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "RSS Reader",
|
||||
"short_name": "RSS Reader",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1a8f5e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useFeeds } from '../composables/useFeeds'
|
||||
|
||||
// router/index.js's route definitions lazily import(...) the view components,
|
||||
// which importing the real module would still be safe to do (the imports
|
||||
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
|
||||
// depending on the app's actual routes and lets currentRoute/push be
|
||||
// controlled directly.
|
||||
vi.mock('../router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { name: 'feeds' } },
|
||||
push: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import router from '../router'
|
||||
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
|
||||
|
||||
describe('handleUnauthorizedResponse', () => {
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'abc123')
|
||||
localStorage.setItem('user-id', '7')
|
||||
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
|
||||
feedFilter.value = ['Feed A']
|
||||
router.currentRoute.value.name = 'feeds'
|
||||
router.push.mockClear()
|
||||
})
|
||||
|
||||
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
|
||||
const error = { response: { status: 401 } }
|
||||
|
||||
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||
|
||||
expect(localStorage.getItem('user-token')).toBeNull()
|
||||
expect(localStorage.getItem('user-id')).toBeNull()
|
||||
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||
expect(feedFilter.value).toBeNull()
|
||||
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
|
||||
})
|
||||
|
||||
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
|
||||
router.currentRoute.value.name = 'login'
|
||||
const error = { response: { status: 401 } }
|
||||
|
||||
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||
|
||||
expect(localStorage.getItem('user-token')).toBeNull()
|
||||
expect(router.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
|
||||
const error = { response: { status: 500 } }
|
||||
|
||||
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||
|
||||
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
|
||||
expect(feedFilter.value).toEqual(['Feed A'])
|
||||
expect(router.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
|
||||
const error = new Error('network')
|
||||
|
||||
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||
|
||||
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||
expect(router.push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const showModal = ref(false)
|
||||
const feeds = ref([])
|
||||
const error = ref('')
|
||||
|
||||
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
|
||||
<template>
|
||||
<div class="admin">
|
||||
<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-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||
<ul v-else class="admin__list">
|
||||
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
|
||||
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport to="body">
|
||||
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</Modal>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
|
||||
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__empty {
|
||||
opacity: 0.6;
|
||||
|
||||
+107
-42
@@ -2,20 +2,38 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
import FeedFilter from './modal/FeedFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
||||
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||
|
||||
const headerRef = ref(null)
|
||||
const filterBtnRef = ref(null)
|
||||
const filterModalOpen = ref(false)
|
||||
|
||||
function openFilterModal() {
|
||||
refreshUnreadDisplay()
|
||||
filterModalOpen.value = true
|
||||
}
|
||||
|
||||
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
|
||||
// so there's nothing to check here before applying it.
|
||||
async function handleFilterClose(selection) {
|
||||
filterModalOpen.value = false
|
||||
await setFeedFilter(selection)
|
||||
filterBtnRef.value?.focus()
|
||||
}
|
||||
|
||||
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||
// back in on scroll-up. This is show/hide via `transform` (not the old
|
||||
// resize behaviour) — the header is position:fixed, so translating it never
|
||||
// reflows content, and the app's programmatic scrolls resolve to sensible
|
||||
// states: scrollTo(0, 0) lands near the top → shown; the list-view
|
||||
// read-correction scrollBy moves only a few px → stays under the threshold.
|
||||
// back in only on scroll-up — never merely by proximity to the top. This is
|
||||
// show/hide via `transform` (not the old resize behaviour) — the header is
|
||||
// position:fixed, so translating it never reflows content. The app's
|
||||
// programmatic scrolls resolve to sensible states without any top-of-page
|
||||
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
|
||||
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
|
||||
// reveals the header that way; the list-view read-correction scrollBy moves
|
||||
// only a few px → stays under the threshold and leaves a hidden header hidden.
|
||||
const hidden = ref(false)
|
||||
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
||||
// When the feed list mutates itself (read-correction scrollBy + array-splice
|
||||
@@ -30,12 +48,10 @@ let accumulated = 0
|
||||
|
||||
function onScroll() {
|
||||
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
|
||||
// open (the dropdown is anchored to the header, so hiding it would slide the
|
||||
// open menu off-screen).
|
||||
if (y <= headerH || menuOpen.value) {
|
||||
// Keep the header visible while the menu is open (the dropdown is anchored
|
||||
// to the header, so hiding it would slide the open menu off-screen).
|
||||
if (menuOpen.value) {
|
||||
hidden.value = false
|
||||
accumulated = 0
|
||||
lastY = y
|
||||
@@ -78,6 +94,7 @@ onMounted(() => {
|
||||
lastY = Math.max(0, window.scrollY)
|
||||
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
refreshUnreadDisplay()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -127,11 +144,6 @@ function handleMarkAllRead() {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
showModal.value = true
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function handleToggleViewMode() {
|
||||
toggleViewMode()
|
||||
closeMenu()
|
||||
@@ -147,16 +159,36 @@ function handleToggleLayout() {
|
||||
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||
<div class="app-nav__wrapper">
|
||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||
<select
|
||||
<span
|
||||
v-if="isSyncing"
|
||||
class="app-nav__sync-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
v-if="onFeedsPage && feedTitles.length"
|
||||
class="app-nav__filter"
|
||||
:value="feedFilter ?? ''"
|
||||
aria-label="Filter by feed"
|
||||
@change="setFeedFilter($event.target.value || null)"
|
||||
ref="filterBtnRef"
|
||||
class="app-nav__filter-btn"
|
||||
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="filterModalOpen"
|
||||
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||
@click="openFilterModal"
|
||||
>
|
||||
<option value="">All feeds</option>
|
||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<FeedFilter
|
||||
:show="filterModalOpen"
|
||||
:titles="feedTitles"
|
||||
:counts="displayedFeedUnreadCounts"
|
||||
:selected="feedFilter"
|
||||
@close="handleFilterClose"
|
||||
/>
|
||||
</Teleport>
|
||||
<button
|
||||
class="app-nav__hamburger"
|
||||
type="button"
|
||||
@@ -190,20 +222,11 @@ function handleToggleLayout() {
|
||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||
</template>
|
||||
<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>
|
||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<Teleport to="body">
|
||||
<Modal :show="showModal" @close="showModal = false">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</Modal>
|
||||
</Teleport>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +249,7 @@ function handleToggleLayout() {
|
||||
|
||||
.app-nav__wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
@@ -234,7 +257,11 @@ function handleToggleLayout() {
|
||||
}
|
||||
|
||||
.app-nav__title {
|
||||
margin-right: auto;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||
}
|
||||
@@ -244,27 +271,65 @@ function handleToggleLayout() {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.app-nav__filter {
|
||||
.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;
|
||||
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 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
min-height: 44px;
|
||||
max-width: clamp(120px, 40vw, 220px);
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-width: 44px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-nav__filter:hover {
|
||||
.app-nav__filter-btn:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.app-nav__filter-btn--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-nav__hamburger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0.5rem;
|
||||
|
||||
+47
-177
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||
import { onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import { useFeeds } from '@/composables/useFeeds';
|
||||
|
||||
const {
|
||||
@@ -17,10 +17,9 @@ const {
|
||||
disconnectObserver,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
markCurrentArticleRead,
|
||||
} = useFeeds()
|
||||
|
||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||
|
||||
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
||||
|
||||
function scrollToNextArticle() {
|
||||
@@ -34,43 +33,14 @@ function scrollToNextArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe-to-navigate in article view. The article visually tracks the finger
|
||||
// while dragging (dragOffsetX/isDragging below), then either completes the
|
||||
// page-turn or springs back to center on release. This is transient
|
||||
// per-gesture state, not shared app state, so it's kept local to this
|
||||
// component rather than in useFeeds() — nothing outside this component needs
|
||||
// it.
|
||||
const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back
|
||||
const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead
|
||||
|
||||
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
|
||||
// app state, so it's kept as plain component-local variables rather than in
|
||||
// useFeeds() — nothing outside this component needs it.
|
||||
let touchStartX = null
|
||||
let touchStartY = null
|
||||
let touchStartTime = null
|
||||
let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page
|
||||
let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it
|
||||
let dragRafPending = false
|
||||
let pendingDragOffset = 0
|
||||
|
||||
const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll)
|
||||
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn
|
||||
const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio
|
||||
const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists
|
||||
|
||||
// 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()
|
||||
}
|
||||
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
|
||||
|
||||
function onArticleTouchStart(event) {
|
||||
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
||||
@@ -78,79 +48,27 @@ function onArticleTouchStart(event) {
|
||||
// — those should scroll the block, not page the article.
|
||||
if (event.touches.length !== 1 || event.target.closest('pre')) {
|
||||
touchStartX = null
|
||||
touchStartY = null
|
||||
return
|
||||
}
|
||||
const touch = event.touches[0]
|
||||
touchStartX = touch.clientX
|
||||
touchStartY = touch.clientY
|
||||
touchStartTime = Date.now()
|
||||
gestureAxis = null
|
||||
const article = event.currentTarget.querySelector('.article-feature')
|
||||
articleWidth = article ? article.offsetWidth : window.innerWidth
|
||||
}
|
||||
|
||||
function onArticleTouchMove(event) {
|
||||
if (touchStartX === null || event.touches.length !== 1) return
|
||||
const touch = event.touches[0]
|
||||
const deltaX = touch.clientX - touchStartX
|
||||
const deltaY = touch.clientY - touchStartY
|
||||
|
||||
if (gestureAxis === null) {
|
||||
if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return
|
||||
gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
|
||||
if (gestureAxis === 'horizontal') isDragging.value = true
|
||||
}
|
||||
if (gestureAxis !== 'horizontal') return // let the page scroll normally instead
|
||||
|
||||
// Stop the page from also scrolling vertically (and the browser's own
|
||||
// horizontal-overscroll gesture) while we're driving the drag ourselves.
|
||||
// Requires this listener to not be passive.
|
||||
event.preventDefault()
|
||||
|
||||
const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) ||
|
||||
(deltaX > 0 && currentIndex.value === 0)
|
||||
pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX
|
||||
|
||||
// rAF-throttled: touchmove can fire faster than the display refreshes. If a
|
||||
// frame is already scheduled, later events in that window just update
|
||||
// pendingDragOffset above — the callback always reads the latest value
|
||||
// once it actually runs, rather than whatever triggered the first one.
|
||||
if (dragRafPending) return
|
||||
dragRafPending = true
|
||||
requestAnimationFrame(() => {
|
||||
dragOffsetX.value = pendingDragOffset
|
||||
dragRafPending = false
|
||||
})
|
||||
touchStartX = event.touches[0].clientX
|
||||
touchStartY = event.touches[0].clientY
|
||||
}
|
||||
|
||||
function onArticleTouchEnd(event) {
|
||||
if (touchStartX === null) return
|
||||
const wasDraggingHorizontally = gestureAxis === 'horizontal'
|
||||
// Computed fresh from the release position rather than read back from
|
||||
// dragOffsetX, which is rAF-throttled and so can be a frame stale.
|
||||
const deltaX = event.changedTouches[0].clientX - touchStartX
|
||||
const elapsed = Date.now() - touchStartTime
|
||||
|
||||
const touch = event.changedTouches[0]
|
||||
const deltaX = touch.clientX - touchStartX
|
||||
const deltaY = touch.clientY - touchStartY
|
||||
touchStartX = null
|
||||
touchStartY = null
|
||||
gestureAxis = null
|
||||
isDragging.value = false
|
||||
|
||||
if (!wasDraggingHorizontally) return
|
||||
|
||||
// Date.now() only has ~1ms resolution, so a genuinely fast flick often
|
||||
// measures as elapsed === 0 — treat that as "as fast as it gets" (clears
|
||||
// the flick check) rather than dividing by zero into "no velocity at all".
|
||||
const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0
|
||||
const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick
|
||||
dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced
|
||||
|
||||
if (!shouldComplete) return
|
||||
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
||||
|
||||
if (deltaX < 0) {
|
||||
navigateToNextArticle()
|
||||
nextArticle()
|
||||
} else {
|
||||
navigateToPrevArticle()
|
||||
prevArticle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +121,11 @@ onBeforeUnmount(() => {
|
||||
onMounted(async () => {
|
||||
setInitialLoad(false)
|
||||
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)
|
||||
setTimeout(function () {
|
||||
setInitialLoad(true)
|
||||
@@ -246,13 +169,7 @@ onMounted(async () => {
|
||||
>↓</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="article-single"
|
||||
@touchstart.passive="onArticleTouchStart"
|
||||
@touchmove="onArticleTouchMove"
|
||||
@touchend.passive="onArticleTouchEnd"
|
||||
>
|
||||
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
|
||||
<div v-if="feeds.length == 0" class="empty-state">
|
||||
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@@ -260,25 +177,22 @@ onMounted(async () => {
|
||||
</svg>
|
||||
<p class="empty-state__label">All caught up</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Transition :name="`slide-${slideDirection}`">
|
||||
<article
|
||||
class="article-feature"
|
||||
:class="{ 'article-feature--dragging': isDragging }"
|
||||
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
|
||||
:key="currentIndex"
|
||||
>
|
||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||
<p class="feed-original-link">
|
||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||
</p>
|
||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||
</article>
|
||||
</Transition>
|
||||
</template>
|
||||
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
|
||||
component forces a synchronous whole-document reflow
|
||||
(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>
|
||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||
<p class="feed-original-link">
|
||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||
</p>
|
||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||
</article>
|
||||
|
||||
<div class="article-nav">
|
||||
<button
|
||||
@@ -286,14 +200,14 @@ onMounted(async () => {
|
||||
class="article-nav__btn"
|
||||
:disabled="currentIndex === 0"
|
||||
aria-label="Previous article"
|
||||
@click="navigateToPrevArticle"
|
||||
@click="prevArticle"
|
||||
>↑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-nav__btn"
|
||||
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||
aria-label="Next article"
|
||||
@click="navigateToNextArticle"
|
||||
@click="nextArticle"
|
||||
>↓</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,62 +357,18 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
padding-top: 1em;
|
||||
padding-bottom: 5rem;
|
||||
/* Fill the viewport below the fixed header even when the article content
|
||||
is short, so swipe-to-navigate works anywhere on screen instead of only
|
||||
over the (possibly small) content area. dvh is the accurate unit on
|
||||
mobile, where this matters most; vh is the fallback for browsers
|
||||
without dvh support. */
|
||||
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
|
||||
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
|
||||
}
|
||||
|
||||
.article-feature {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
/* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a
|
||||
cancelled swipe). Overridden by .article-feature--dragging (below, so it
|
||||
wins the tie) while actively tracking a touchmove, and by the
|
||||
.slide-next/.slide-prev *-active rules (further below, so they win too)
|
||||
during an actual page-turn — both need their own transform transition
|
||||
timing instead. */
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.article-feature--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { nextTick } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import AppNav from '../AppNav.vue'
|
||||
import FeedFilter from '../modal/FeedFilter.vue'
|
||||
import { useFeeds } from '../../composables/useFeeds'
|
||||
|
||||
vi.mock('axios')
|
||||
@@ -25,7 +26,7 @@ describe('AppNav', () => {
|
||||
localStorage.setItem('user-id', '7')
|
||||
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 = []
|
||||
allItems.value = []
|
||||
feedFilter.value = null
|
||||
@@ -34,7 +35,6 @@ describe('AppNav', () => {
|
||||
lastProgrammaticScroll.value = 0
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
viewMode.value = 'list'
|
||||
currentIndex.value = 0
|
||||
layout.value = 'list'
|
||||
@@ -130,16 +130,6 @@ describe('AppNav', () => {
|
||||
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 () => {
|
||||
const wrapper = await mountWithMenuOpen()
|
||||
const { viewMode } = useFeeds()
|
||||
@@ -229,7 +219,7 @@ describe('AppNav', () => {
|
||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
|
||||
it('opens a feed filter modal listing "All feeds" plus one entry per distinct feed', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
||||
@@ -240,17 +230,20 @@ describe('AppNav', () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
|
||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
||||
// its unread count.
|
||||
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
|
||||
const modal = wrapper.findComponent(FeedFilter)
|
||||
expect(modal.props('show')).toBe(true)
|
||||
// Distinct titles, sorted alphabetically, with per-feed unread counts.
|
||||
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
|
||||
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||
})
|
||||
|
||||
it('does not render the feed filter when there are no feeds', async () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
||||
@@ -263,14 +256,16 @@ describe('AppNav', () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.find('.app-nav__filter')
|
||||
await select.setValue('Feed A')
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
const modal = wrapper.findComponent(FeedFilter)
|
||||
modal.vm.$emit('close', ['Feed A'])
|
||||
await flushPromises()
|
||||
|
||||
expect(feedFilter.value).toBe('Feed A')
|
||||
expect(feedFilter.value).toEqual(['Feed A'])
|
||||
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||
|
||||
await select.setValue('')
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
modal.vm.$emit('close', null)
|
||||
await flushPromises()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
@@ -291,13 +286,178 @@ describe('AppNav', () => {
|
||||
// No filter → global total (3).
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
||||
|
||||
await wrapper.find('.app-nav__filter').setValue('Feed A')
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||
await flushPromises()
|
||||
|
||||
// Filtered → unread in Feed A only (2).
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('sums the unread count across every selected feed in the title badge', async () => {
|
||||
const { allItems, feedFilter } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||
]
|
||||
feedFilter.value = ['Feed A', 'Feed B']
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
|
||||
const { allItems, feedFilter } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
const button = wrapper.find('.app-nav__filter-btn')
|
||||
|
||||
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
|
||||
expect(button.attributes('aria-label')).toBe('Filter feeds')
|
||||
expect(button.attributes('title')).toBe('Filter feeds')
|
||||
|
||||
feedFilter.value = ['Feed A']
|
||||
await nextTick()
|
||||
|
||||
expect(button.classes()).toContain('app-nav__filter-btn--active')
|
||||
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
|
||||
expect(button.attributes('title')).toBe('Filter feeds (active)')
|
||||
})
|
||||
|
||||
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
const button = wrapper.find('.app-nav__filter-btn')
|
||||
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
|
||||
await button.trigger('click')
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||
await flushPromises()
|
||||
|
||||
expect(button.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
|
||||
const { allItems, feedFilter } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||
]
|
||||
feedFilter.value = ['Feed A', 'Feed B']
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
|
||||
await flushPromises()
|
||||
|
||||
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
|
||||
// no-op path (sameFeedSelection) was taken instead.
|
||||
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||
})
|
||||
|
||||
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||
|
||||
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||
await flushPromises()
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns focus to the filter button once the modal closes', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const button = wrapper.find('.app-nav__filter-btn')
|
||||
|
||||
await button.trigger('click')
|
||||
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
})
|
||||
|
||||
describe('sync spinner', () => {
|
||||
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders while a sync triggered through the composable is in flight, and disappears 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').exists()).toBe(true)
|
||||
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
resolvePost({ status: 200 })
|
||||
await syncPromise
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders while syncing 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()
|
||||
|
||||
let resolvePost
|
||||
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||
const { sync } = useFeeds()
|
||||
const syncPromise = sync(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
resolvePost({ status: 200 })
|
||||
await syncPromise
|
||||
})
|
||||
})
|
||||
|
||||
describe('scroll-driven show/hide', () => {
|
||||
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||
@@ -306,9 +466,17 @@ describe('AppNav', () => {
|
||||
// Reset scroll position so each mount's lastY baseline starts at 0.
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
||||
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||
// offsetHeight is 0 in jsdom; give the header a real height so the
|
||||
// "near the top" guard (scrollY <= headerH) has something to compare to.
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
|
||||
// getBoundingClientRect() is all-zero in jsdom; give the header a real
|
||||
// height so onMounted()'s one-time measurement (used for the
|
||||
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
|
||||
// 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(() => {
|
||||
@@ -341,19 +509,38 @@ describe('AppNav', () => {
|
||||
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||
})
|
||||
|
||||
it('always shows the header near the top of the page', async () => {
|
||||
it('reveals the header on a large upward jump that lands near the top', async () => {
|
||||
const wrapper = mountNav()
|
||||
scrollTo(400)
|
||||
await nextTick()
|
||||
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||
|
||||
// Back within the header's own height of the top → always revealed.
|
||||
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
|
||||
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
|
||||
scrollTo(10)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||
})
|
||||
|
||||
it('does not reveal the header merely for landing near the top', async () => {
|
||||
const wrapper = mountNav()
|
||||
// Hide it with a jump just past the header's own height (50px, mocked
|
||||
// above) plus the reveal threshold.
|
||||
scrollTo(62)
|
||||
await nextTick()
|
||||
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||
|
||||
// Move back to right at the header's own height — within the "near the
|
||||
// top" zone the old code always revealed for — but the upward delta
|
||||
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
|
||||
// hidden: proximity to the top is no longer sufficient on its own.
|
||||
scrollTo(50)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||
})
|
||||
|
||||
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||
const { markProgrammaticScroll } = useFeeds()
|
||||
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
|
||||
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
|
||||
|
||||
// useFeeds() returns module-level singleton refs shared across the whole
|
||||
// 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 = []
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
viewMode.value = 'list'
|
||||
currentIndex.value = 0
|
||||
layout.value = 'list'
|
||||
@@ -360,261 +359,178 @@ describe('RssFeeds', () => {
|
||||
expect(titles).toEqual(['Article three'])
|
||||
})
|
||||
|
||||
describe('swipe navigation in article view', () => {
|
||||
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once
|
||||
// per frame) and reads the article's real offsetWidth for the
|
||||
// commit-distance check — stub both so gestures are deterministic. Per
|
||||
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd
|
||||
// also fake rAF and clobber this manual stub. (Faking Date, even scoped,
|
||||
// was tried for the release-velocity check below and broke test-utils'
|
||||
// event dispatch outright — real small setTimeout delays instead.)
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400)
|
||||
it('swipes left to navigate to the next article', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('swipes right to navigate to the previous article', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate on a mostly-vertical drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
|
||||
})
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
afterEach(() => {
|
||||
// Not vi.unstubAllGlobals(): it would also remove the file-level
|
||||
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and
|
||||
// every test in here re-stubs requestAnimationFrame in beforeEach
|
||||
// anyway, so there's nothing to reset it back to for.
|
||||
vi.restoreAllMocks()
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate when a swipe starts inside a code block', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
// Drives one full touchstart -> touchmove -> touchend gesture. delayMs is
|
||||
// a genuine wall-clock wait before releasing — it's how tests control the
|
||||
// release velocity (deltaX / elapsed) the component computes, alongside
|
||||
// the distance dragged.
|
||||
async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) {
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] })
|
||||
if (delayMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] })
|
||||
await flushPromises()
|
||||
}
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
it('drags the article to follow the finger while swiping', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
const dragged = wrapper.find('.article-feature')
|
||||
expect(dragged.classes()).toContain('article-feature--dragging')
|
||||
expect(dragged.attributes('style')).toContain('translateX(-50px)')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not throw when swiping with an empty feed list', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
it('completes the page-turn on a swipe past the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px.
|
||||
await swipe(wrapper, { startX: 300, endX: 80 })
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
expect(wrapper.text()).toContain('All caught up')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('completes the page-turn backwards on a swipe past the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
await swipe(wrapper, { startX: 80, endX: 300 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('springs back without navigating on a short, slow drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// Only 20px of a 400px-wide article (well under the 140px distance
|
||||
// threshold), released slowly enough that velocity doesn't qualify
|
||||
// either (well under the 0.5px/ms flick threshold).
|
||||
await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('completes the page-turn on a fast flick even under the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// Only 60px (well under the 140px distance threshold), released with
|
||||
// no extra delay. Date.now() only has ~1ms resolution, so a gesture
|
||||
// this fast typically measures as 0ms elapsed — the component treats
|
||||
// that as clearing the flick check outright (see the comment by
|
||||
// isFastFlick in onArticleTouchEnd) rather than as "no velocity".
|
||||
await swipe(wrapper, { startX: 200, endX: 140 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not drag or navigate on a mostly-vertical drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate when the vertical component dominates a diagonal drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// More horizontal travel (70px) than in the previous test, but the
|
||||
// vertical component (100px) is still larger — locks as a vertical
|
||||
// scroll, not a swipe, regardless of the sizeable deltaX.
|
||||
await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies resistance when dragging past the last article', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next"
|
||||
|
||||
// Damped to 35% of the raw -100px drag, not the full amount.
|
||||
expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
|
||||
})
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not drag or navigate when a swipe starts inside a code block', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] })
|
||||
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not throw when swiping with an empty feed list', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await swipe(wrapper, { startX: 200, endX: 80 })
|
||||
|
||||
expect(wrapper.text()).toContain('All caught up')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import '@/assets/modal.css';
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
const emit = defineEmits(['close', 'added'])
|
||||
const submitted = ref(false)
|
||||
const url = ref('')
|
||||
const title = ref('')
|
||||
@@ -30,6 +31,7 @@ async function save() {
|
||||
|
||||
console.log(response)
|
||||
output.value = 'saved successfully'
|
||||
emit('added')
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
output.value = error.message
|
||||
@@ -57,7 +59,7 @@ async function save() {
|
||||
<div class="modal-footer">
|
||||
<slot name="footer">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import '@/assets/modal.css';
|
||||
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
titles: { type: Array, default: () => [] },
|
||||
counts: { type: Object, default: () => ({}) },
|
||||
selected: { type: Array, default: null },
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// Component-local draft: seeded from `selected` each time the modal opens
|
||||
// (including on initial mount if it's already open — see the `immediate`
|
||||
// watcher below) and only reported back (via `close`) once it's dismissed —
|
||||
// see AppNav.vue for how the emitted selection is applied.
|
||||
const draft = ref(null)
|
||||
const allFeedsCheckboxRef = ref(null)
|
||||
|
||||
function isChecked(title) {
|
||||
return draft.value !== null && draft.value.includes(title)
|
||||
}
|
||||
|
||||
function onToggleAll(event) {
|
||||
if (event.target.checked) {
|
||||
draft.value = null
|
||||
} else {
|
||||
// The selection is never allowed to be empty — "All feeds" is the
|
||||
// fallback state, so it can't be unchecked without checking a feed first.
|
||||
event.target.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleFeed(title, checked) {
|
||||
if (checked) {
|
||||
draft.value = draft.value === null ? [title] : [...draft.value, title]
|
||||
} else {
|
||||
const remaining = (draft.value ?? []).filter(t => t !== title)
|
||||
// Unchecking the last selected feed falls back to "All feeds" — see
|
||||
// onToggleAll, the selection is never allowed to be empty.
|
||||
draft.value = remaining.length > 0 ? remaining : null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close', draft.value)
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Escape') handleClose()
|
||||
}
|
||||
|
||||
// While the modal is open, scrolling the page behind it would run through
|
||||
// list view's IntersectionObserver and mark articles read out from under the
|
||||
// user — lock it, and restore whatever it was before. `''` is a legitimate
|
||||
// saved overflow value, so `isLocked` (not a null-check on the saved value)
|
||||
// tracks whether there's actually anything to restore — otherwise unlocking
|
||||
// on unmount without ever having locked would stomp a value this component
|
||||
// never touched.
|
||||
let previousBodyOverflow = ''
|
||||
let isLocked = false
|
||||
function lockBodyScroll() {
|
||||
previousBodyOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
isLocked = true
|
||||
}
|
||||
function unlockBodyScroll() {
|
||||
if (!isLocked) return
|
||||
document.body.style.overflow = previousBodyOverflow
|
||||
isLocked = false
|
||||
}
|
||||
|
||||
watch(() => props.show, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
draft.value = props.selected ? [...props.selected] : null
|
||||
lockBodyScroll()
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
await nextTick()
|
||||
allFeedsCheckboxRef.value?.focus()
|
||||
} else {
|
||||
unlockBodyScroll()
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
unlockBodyScroll()
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="modal-mask" @click.self="handleClose">
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
|
||||
<div class="modal-header">
|
||||
<h3 id="feed-filter-heading">Filter feeds</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="feed-filter__option">
|
||||
<input
|
||||
ref="allFeedsCheckboxRef"
|
||||
type="checkbox"
|
||||
:checked="draft === null"
|
||||
@change="onToggleAll"
|
||||
/>
|
||||
All feeds
|
||||
</label>
|
||||
<label v-for="title in titles" :key="title" class="feed-filter__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isChecked(title)"
|
||||
@change="onToggleFeed(title, $event.target.checked)"
|
||||
/>
|
||||
{{ title }} ({{ counts[title] ?? 0 }})
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" @click="handleClose">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feed-filter__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
|
||||
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
|
||||
here so checkboxes don't render full-width and oversized. */
|
||||
.feed-filter__option input[type='checkbox'] {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
expect(wrapper.text()).toContain('saved successfully')
|
||||
expect(wrapper.emitted('added')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces the error message when the request fails', async () => {
|
||||
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
|
||||
await flushPromises()
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import FeedFilter from '../FeedFilter.vue'
|
||||
|
||||
describe('FeedFilter', () => {
|
||||
const previousBodyOverflow = document.body.style.overflow
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = previousBodyOverflow
|
||||
})
|
||||
|
||||
it('renders nothing when show is false', () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: ['Feed A'], counts: { 'Feed A': 2 }, selected: null },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.modal-mask').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders "All feeds" plus one checkbox per title, defaulting a missing count to 0', () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: { 'Feed A': 2 }, selected: null },
|
||||
})
|
||||
|
||||
const options = wrapper.findAll('.feed-filter__option')
|
||||
expect(options).toHaveLength(3)
|
||||
expect(options[0].text()).toBe('All feeds')
|
||||
expect(options[1].text()).toBe('Feed A (2)')
|
||||
expect(options[2].text()).toBe('Feed B (0)')
|
||||
})
|
||||
|
||||
it('checks "All feeds" and leaves every feed unchecked (but enabled) when nothing is selected', () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes[0].element.checked).toBe(true)
|
||||
expect(checkboxes[1].element.checked).toBe(false)
|
||||
expect(checkboxes[1].element.disabled).toBe(false)
|
||||
expect(checkboxes[2].element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('checking a feed unchecks "All feeds"', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||
})
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
|
||||
await checkboxes[1].setValue(true)
|
||||
|
||||
expect(checkboxes[0].element.checked).toBe(false)
|
||||
expect(checkboxes[1].element.checked).toBe(true)
|
||||
expect(checkboxes[2].element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('checking a second feed keeps both checked, with "All feeds" still unchecked', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||
})
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
|
||||
await checkboxes[2].setValue(true)
|
||||
|
||||
expect(checkboxes[0].element.checked).toBe(false)
|
||||
expect(checkboxes[1].element.checked).toBe(true)
|
||||
expect(checkboxes[2].element.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('unchecking the last selected feed falls back to "All feeds"', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||
})
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
|
||||
await checkboxes[1].setValue(false)
|
||||
|
||||
expect(checkboxes[0].element.checked).toBe(true)
|
||||
expect(checkboxes[1].element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('checking "All feeds" clears every selected feed', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A'], counts: {}, selected: ['Feed A'] },
|
||||
})
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes[0].element.checked).toBe(false)
|
||||
expect(checkboxes[1].element.checked).toBe(true)
|
||||
|
||||
await checkboxes[0].setValue(true)
|
||||
|
||||
expect(checkboxes[0].element.checked).toBe(true)
|
||||
expect(checkboxes[1].element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('unchecking "All feeds" while it is active leaves it checked — the selection is never empty', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||
})
|
||||
const allCheckbox = wrapper.findAll('input[type="checkbox"]')[0]
|
||||
|
||||
await allCheckbox.setValue(false)
|
||||
|
||||
expect(allCheckbox.element.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('emits close with the current draft when Done is clicked', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||
})
|
||||
await wrapper.findAll('input[type="checkbox"]')[1].setValue(true)
|
||||
|
||||
await wrapper.find('.modal-footer button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||
})
|
||||
|
||||
it('emits close with the current draft on Escape, but not again once closed', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||
})
|
||||
await wrapper.setProps({ show: true })
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||
|
||||
await wrapper.setProps({ show: false })
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits close on a backdrop click, but not on a click inside the modal container', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
await wrapper.find('.modal-header').trigger('click')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
|
||||
await wrapper.find('.modal-mask').trigger('click')
|
||||
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||
})
|
||||
|
||||
it('reseeds the draft from `selected` on reopen, discarding an abandoned edit', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||
})
|
||||
// Edit the draft without confirming via Done/close.
|
||||
await wrapper.findAll('input[type="checkbox"]')[2].setValue(true)
|
||||
expect(wrapper.findAll('input[type="checkbox"]')[2].element.checked).toBe(true)
|
||||
|
||||
await wrapper.setProps({ show: false })
|
||||
await wrapper.setProps({ show: true })
|
||||
await nextTick()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes[1].element.checked).toBe(true) // Feed A, from `selected`
|
||||
expect(checkboxes[2].element.checked).toBe(false) // Feed B, abandoned edit dropped
|
||||
})
|
||||
|
||||
it('locks body scroll while open and restores the prior value on close', async () => {
|
||||
document.body.style.overflow = 'scroll'
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: [], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
await wrapper.setProps({ show: false })
|
||||
expect(document.body.style.overflow).toBe('scroll')
|
||||
})
|
||||
|
||||
it('restores the prior body scroll value on unmount while still open', async () => {
|
||||
document.body.style.overflow = 'auto'
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: [], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
|
||||
wrapper.unmount()
|
||||
expect(document.body.style.overflow).toBe('auto')
|
||||
})
|
||||
|
||||
it('focuses the "All feeds" checkbox after opening', async () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.findAll('input[type="checkbox"]')[0].element)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('seeds the draft, locks body scroll, and wires Escape when mounted already open', async () => {
|
||||
document.body.style.overflow = 'auto'
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes[1].element.checked).toBe(true) // Feed A, seeded from `selected`
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
await nextTick()
|
||||
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('leaves a pre-set body overflow untouched when mounted closed and then unmounted', () => {
|
||||
document.body.style.overflow = 'scroll'
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: false, titles: [], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(document.body.style.overflow).toBe('scroll')
|
||||
})
|
||||
|
||||
it('renders dialog accessibility attributes tied to the heading', () => {
|
||||
const wrapper = mount(FeedFilter, {
|
||||
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||
})
|
||||
|
||||
const dialog = wrapper.find('.modal-container')
|
||||
expect(dialog.attributes('role')).toBe('dialog')
|
||||
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||
const labelledby = dialog.attributes('aria-labelledby')
|
||||
expect(wrapper.find(`#${labelledby}`).text()).toBe('Filter feeds')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// loadPersistedFeedFilter() (see useFeeds.js) reads localStorage at module
|
||||
// import time, not lazily — every case here has to vi.resetModules() and
|
||||
// dynamically re-import so the module-scope ref actually re-initializes
|
||||
// against the localStorage value set up beforehand. Mirrors the pattern in
|
||||
// viewModeBoot.spec.js.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('feedFilter boot persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('boots with the persisted array when it is valid', async () => {
|
||||
localStorage.setItem('feedFilter', JSON.stringify(['Feed A', 'Feed B']))
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||
})
|
||||
|
||||
it('boots into "All feeds" when the key is absent', async () => {
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
})
|
||||
|
||||
it('boots into "All feeds" when the persisted array is empty', async () => {
|
||||
localStorage.setItem('feedFilter', JSON.stringify([]))
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a plain object', JSON.stringify({ title: 'Feed A' })],
|
||||
['a bare string', JSON.stringify('Feed A')],
|
||||
])('boots into "All feeds" when the persisted value is not an array (%s)', async (_label, raw) => {
|
||||
localStorage.setItem('feedFilter', raw)
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
})
|
||||
|
||||
it('boots into "All feeds" when the persisted value is not valid JSON', async () => {
|
||||
localStorage.setItem('feedFilter', 'not json')
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['numbers', [1]],
|
||||
['null', [null]],
|
||||
['a mix of strings and non-strings', ['a', 2]],
|
||||
])('boots into "All feeds" when the array contains non-string elements (%s)', async (_label, value) => {
|
||||
localStorage.setItem('feedFilter', JSON.stringify(value))
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { feedFilter } = useFeeds()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,10 @@ describe('viewMode boot persistence', () => {
|
||||
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
+328
-38
@@ -12,18 +12,38 @@ const showMessage = ref(false)
|
||||
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||
const allItems = ref([]);
|
||||
const feeds = ref([]);
|
||||
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
|
||||
// Distinct feed titles present in the loaded (unread) items — the filter options.
|
||||
// The active filter is always kept in the list even once its feed runs out of
|
||||
// unread items, so the <select> never binds to a value that isn't an option
|
||||
// (it just shows "All caught up") and the feed stays re-selectable.
|
||||
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
|
||||
// setFeedFilter) so the selection survives a reload.
|
||||
function loadPersistedFeedFilter() {
|
||||
const raw = localStorage.getItem('feedFilter')
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(title => typeof title === 'string') ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const feedFilter = ref(loadPersistedFeedFilter())
|
||||
// Feed titles are specific to the account that was logged in — call this
|
||||
// wherever a session ends (explicit logout, or a 401 dropping a stale/expired
|
||||
// one in main.js) so they can't leak (in storage or in the still-live module
|
||||
// singleton) into whichever account is active next on this device.
|
||||
export function clearFeedFilter() {
|
||||
localStorage.removeItem('feedFilter')
|
||||
feedFilter.value = null
|
||||
}
|
||||
// Distinct feed titles present in the loaded (unread) items — the filter
|
||||
// checkbox options. Every currently selected title is always kept in the
|
||||
// list even once its feed runs out of unread items, so a checked feed never
|
||||
// disappears out from under the user before they get a chance to uncheck it.
|
||||
const feedTitles = computed(() => {
|
||||
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
||||
if (feedFilter.value) titles.add(feedFilter.value)
|
||||
for (const title of feedFilter.value ?? []) titles.add(title)
|
||||
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
// Unread count per feed title, shown next to each option in the filter
|
||||
// <select>. Derived from the master (allItems) so article view's
|
||||
// Unread count per feed title, shown next to each checkbox in the filter
|
||||
// modal. Derived from the master (allItems) so article view's
|
||||
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
||||
// unread items left has no key here — render it as 0.
|
||||
const feedUnreadCounts = computed(() => {
|
||||
@@ -38,13 +58,26 @@ const feedUnreadCounts = computed(() => {
|
||||
// rather than the displayed `feeds` so article view's read-but-still-shown
|
||||
// items are excluded and the count matches the filter regardless of view.
|
||||
const unreadCount = computed(() => {
|
||||
const items = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value
|
||||
return items.filter(i => !i.read).length
|
||||
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
|
||||
const titles = new Set(feedFilter.value)
|
||||
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
|
||||
})
|
||||
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||
// for the filter modal's per-feed checkbox 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 showModal = ref(false)
|
||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||
const currentIndex = ref(0)
|
||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||
@@ -61,6 +94,63 @@ let initialLoad = false
|
||||
// genuine scroll-driven exits mark read after that.
|
||||
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
|
||||
// mutation that moves the page without user intent — currently the list-view
|
||||
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||
@@ -92,6 +182,7 @@ export async function logout() {
|
||||
}
|
||||
localStorage.removeItem('user-token')
|
||||
localStorage.removeItem('user-id')
|
||||
clearFeedFilter()
|
||||
}
|
||||
|
||||
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
||||
@@ -200,6 +291,11 @@ async function getReadable(feed, index) {
|
||||
const container = el.closest('section') ?? el.closest('article')
|
||||
if (container) container.remove()
|
||||
})
|
||||
// Al Jazeera embeds a "Recommended Stories" section (a heading followed by
|
||||
// a list of unrelated article teasers), marked up as <section class="more-on">.
|
||||
// It's not part of the article, so strip it before Readability pulls it
|
||||
// into the parsed content.
|
||||
doc.querySelectorAll('section.more-on').forEach(el => el.remove())
|
||||
const article = new Readability(doc).parse();
|
||||
if (!article) {
|
||||
showMessageForXSeconds('Could not extract readable content.', 5)
|
||||
@@ -213,12 +309,74 @@ async function getReadable(feed, index) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
|
||||
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
|
||||
// prevArticle() can both re-mark the same article (page forward, back,
|
||||
// forward again), so two overlapping markRead() calls for one id must both
|
||||
// finish before fetchData() is allowed to trust the server's word on it —
|
||||
// otherwise the first call to settle would clear the id out from under the
|
||||
// second, still-in-flight one.
|
||||
const pendingReadCounts = new Map()
|
||||
|
||||
function addPendingRead(id) {
|
||||
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function removePendingRead(id) {
|
||||
const count = pendingReadCounts.get(id) ?? 0
|
||||
if (count <= 1) {
|
||||
pendingReadCounts.delete(id)
|
||||
} else {
|
||||
pendingReadCounts.set(id, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Short backoff between retries of a failed mark-read PUT — each entry is the
|
||||
// wait before that retry attempt.
|
||||
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
|
||||
// Bounds how long a hung request (dead connection, captive portal — axios has
|
||||
// no default timeout) can keep an id "pending": without this, a request that
|
||||
// never settles would never leave pendingReadCounts, silently withholding
|
||||
// that article from every fetchData() for the rest of the session.
|
||||
const MARK_READ_TIMEOUT_MS = 10000
|
||||
|
||||
// 4xx responses (expired token, item not found/not owned — see
|
||||
// src/reader/mark_read.rs) mean the request itself is wrong and won't
|
||||
// succeed on retry; only a missing response (network error, timeout) or a
|
||||
// server-side/rate-limit status is worth retrying.
|
||||
function isRetryableMarkReadError(error) {
|
||||
const status = error.response?.status
|
||||
return status === undefined || status >= 500 || status === 429
|
||||
}
|
||||
|
||||
// Resolves to true once the article is confirmed read server-side, or false
|
||||
// once retries (if any) are exhausted / the error isn't retryable.
|
||||
async function markRead(id) {
|
||||
addPendingRead(id)
|
||||
try {
|
||||
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
|
||||
console.log(response.status)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error marking article read:', error)
|
||||
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
|
||||
// Out of retries, or a retry can't help — this mark never committed
|
||||
// server-side, so the item genuinely is still unread there. Surface
|
||||
// it rather than letting it silently vanish from the local list now
|
||||
// only to mysteriously reappear as unread later.
|
||||
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
|
||||
return false
|
||||
}
|
||||
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
removePendingRead(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +384,48 @@ async function markRead(id) {
|
||||
// feed with no remaining unread items projects to an empty list ("All caught
|
||||
// up") while staying selected — see feedTitles.
|
||||
function applyFilter() {
|
||||
feeds.value = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value.slice()
|
||||
if (!feedFilter.value) {
|
||||
feeds.value = allItems.value.slice()
|
||||
return
|
||||
}
|
||||
const titles = new Set(feedFilter.value)
|
||||
feeds.value = allItems.value.filter(i => titles.has(i.feedTitle))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Order-insensitive: callers building the next selection from checkbox
|
||||
// toggles (see FeedFilter.vue) can produce an array in a different order than
|
||||
// what's already active — that's not a real change.
|
||||
function sameFeedSelection(a, b) {
|
||||
if (a === null || b === null) return a === b
|
||||
if (a.length !== b.length) return false
|
||||
const setA = new Set(a)
|
||||
return b.every(title => setA.has(title))
|
||||
}
|
||||
|
||||
// Shared by setFeedFilter() and pruneFeedFilter() — both assign feedFilter.value
|
||||
// then need the same localStorage write/removal to keep it in sync.
|
||||
function persistFeedFilter() {
|
||||
if (feedFilter.value) {
|
||||
localStorage.setItem('feedFilter', JSON.stringify(feedFilter.value))
|
||||
} else {
|
||||
localStorage.removeItem('feedFilter')
|
||||
}
|
||||
}
|
||||
|
||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||
@@ -236,38 +433,99 @@ function applyFilter() {
|
||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||
// article view too — there are no .observe nodes there so the setup is a no-op,
|
||||
// and resetting currentIndex keeps paging valid against the new list.
|
||||
async function setFeedFilter(title) {
|
||||
// A no-op (order-insensitively unchanged) selection returns early, before any
|
||||
// of the scrollTo/dropReadArticles/mark-read side effects below — every
|
||||
// caller gets this protection for free rather than having to check first.
|
||||
async function setFeedFilter(titles) {
|
||||
const next = titles && titles.length > 0 ? titles : null // null for "All feeds"
|
||||
if (sameFeedSelection(feedFilter.value, next)) return
|
||||
disconnectObserver()
|
||||
window.scrollTo(0, 0)
|
||||
feedFilter.value = title // null for "All feeds"
|
||||
dropReadArticles()
|
||||
feedFilter.value = next
|
||||
persistFeedFilter()
|
||||
currentIndex.value = 0
|
||||
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()
|
||||
await nextTick()
|
||||
setupIntersectionObserver()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
// A persisted/selected title can outlive its feed (deleted or renamed from
|
||||
// Admin) — the modal then has no way to un-list a title the user never got a
|
||||
// chance to see and uncheck. `allItems` can't tell "the feed still exists"
|
||||
// from "the feed has 0 unread items right now" (feedTitles deliberately keeps
|
||||
// the latter listed), so it's not a usable source of truth here; the feed
|
||||
// list itself is, and AdminFeeds.vue already fetches exactly that from this
|
||||
// same endpoint. Only requested when a filter is actually active, since
|
||||
// that's the only case with anything to prune.
|
||||
async function pruneFeedFilter() {
|
||||
if (!feedFilter.value) return
|
||||
try {
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
const response = await axios.get("/api/v1/article/feeds/" + user_id, authHeaders())
|
||||
// The filter may have been cleared (logout / 401) while the request was in flight.
|
||||
if (!feedFilter.value) return
|
||||
const existingTitles = new Set(response.data.feeds.map(feed => feed.title))
|
||||
const pruned = feedFilter.value.filter(title => existingTitles.has(title))
|
||||
if (pruned.length === feedFilter.value.length) return
|
||||
feedFilter.value = pruned.length > 0 ? pruned : null
|
||||
persistFeedFilter()
|
||||
applyFilter()
|
||||
} catch (error) {
|
||||
// Best-effort: a stale title stuck in the modal until the next successful
|
||||
// prune is harmless, so don't fail the reload over it.
|
||||
console.error('Error pruning feed filter', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
beginSync()
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
// Snapshot ids pending *before* the GET goes out, not just when its
|
||||
// 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
|
||||
// cleared by the time the response arrives) even though the server read
|
||||
// the DB before that commit and so still reported the item unread — this
|
||||
// snapshot catches that case too.
|
||||
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
|
||||
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
|
||||
const items = [];
|
||||
response.data.feeds.forEach(feed => {
|
||||
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
|
||||
});
|
||||
// An item pending at either end of this request may not have committed
|
||||
// server-side yet, so the server can still report it unread here. Drop it
|
||||
// from this snapshot too — otherwise a concurrent background sync's
|
||||
// refetch (see sync()) would resurrect it as unread out from under the
|
||||
// user while markRead() is still confirming or retrying it.
|
||||
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
|
||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||
// lexicographic comparison sorts them chronologically.
|
||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
allItems.value = items;
|
||||
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
allItems.value = freshItems;
|
||||
applyFilter();
|
||||
await pruneFeedFilter();
|
||||
refreshUnreadDisplay();
|
||||
await nextTick();
|
||||
setupIntersectionObserver();
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
showMessageForXSeconds(error, 5)
|
||||
} finally {
|
||||
endSync()
|
||||
}
|
||||
};
|
||||
|
||||
async function sync(silent = false) {
|
||||
beginSync()
|
||||
try {
|
||||
const response = await axios.post('/api/v1/article/sync', {
|
||||
user_id: parseInt(localStorage.getItem("user-id"))
|
||||
@@ -276,12 +534,14 @@ async function sync(silent = false) {
|
||||
if (response.status == 200 && !silent) {
|
||||
showMessageForXSeconds('Sync successful.', 5)
|
||||
}
|
||||
fetchData();
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error sync', error)
|
||||
if (!silent) {
|
||||
showMessageForXSeconds(error, 5)
|
||||
}
|
||||
} finally {
|
||||
endSync()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +590,17 @@ function handleIntersection(entries, topbarHeight = 0) {
|
||||
|
||||
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
|
||||
// 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 —
|
||||
@@ -346,6 +617,7 @@ function handleIntersection(entries, topbarHeight = 0) {
|
||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
refreshUnreadDisplay()
|
||||
|
||||
for (const feed of readFeeds) {
|
||||
markRead(feed.id)
|
||||
@@ -392,32 +664,47 @@ async function markAllRead() {
|
||||
// is active) — drop exactly those from the master too.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
currentIndex.value = 0
|
||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||
await Promise.all(ids.map(id => markRead(id)))
|
||||
showMessageForXSeconds('All articles marked as read.', 5)
|
||||
refreshUnreadDisplay()
|
||||
// markRead() resolves to true/false rather than rejecting, so Promise.all
|
||||
// can't reject here — but a false means that article is still unread
|
||||
// server-side, which the message below must not contradict.
|
||||
const results = await Promise.all(ids.map(id => markRead(id)))
|
||||
const failedCount = results.filter(ok => !ok).length
|
||||
if (failedCount === 0) {
|
||||
showMessageForXSeconds('All articles marked as read.', 5)
|
||||
} else {
|
||||
// markRead() already surfaced each individual failure — this summarizes
|
||||
// the batch outcome instead of claiming full success over it.
|
||||
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
|
||||
}
|
||||
}
|
||||
|
||||
function markCurrentArticleRead() {
|
||||
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
|
||||
// view does) keeps the array stable so currentIndex stays valid while paging.
|
||||
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
||||
if (feed) {
|
||||
feed.read = true
|
||||
markRead(feed.id)
|
||||
}
|
||||
feed.read = true
|
||||
markRead(feed.id)
|
||||
}
|
||||
|
||||
async function leaveArticleView() {
|
||||
// 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
|
||||
// showing up in the list view.
|
||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||
// Shared references — the paged-past objects carry .read on the master too.
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
dropReadArticles()
|
||||
currentIndex.value = 0
|
||||
viewMode.value = 'list'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
refreshUnreadDisplay()
|
||||
// The v-if on the list container tears down and recreates all .observe DOM
|
||||
// nodes when switching views, so the intersection observer must be
|
||||
// re-pointed at the new elements after Vue has finished rendering.
|
||||
@@ -479,14 +766,16 @@ export function useFeeds() {
|
||||
feedTitles,
|
||||
feedUnreadCounts,
|
||||
unreadCount,
|
||||
displayedFeedUnreadCounts,
|
||||
refreshUnreadDisplay,
|
||||
setFeedFilter,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
toggleViewMode,
|
||||
leaveArticleView,
|
||||
markCurrentArticleRead,
|
||||
layout,
|
||||
toggleLayout,
|
||||
nextArticle,
|
||||
@@ -503,5 +792,6 @@ export function useFeeds() {
|
||||
handleIntersection,
|
||||
lastProgrammaticScroll,
|
||||
markProgrammaticScroll,
|
||||
isSyncing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import router from './router'
|
||||
import { clearFeedFilter } from './composables/useFeeds'
|
||||
|
||||
// Extracted out of main.js so it's unit-testable in isolation: importing
|
||||
// main.js itself boots the whole app (createApp(...).mount('#app')) and
|
||||
// eagerly touches the router's lazy-loaded route components, neither of
|
||||
// which a test can do safely.
|
||||
//
|
||||
// A 401 means the server has rejected the token (missing, expired, or
|
||||
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
||||
// and send the user back to login rather than leaving them on a page where
|
||||
// every request silently fails.
|
||||
export function handleUnauthorizedResponse(error) {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('user-token')
|
||||
localStorage.removeItem('user-id')
|
||||
clearFeedFilter()
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
+2
-14
@@ -4,23 +4,11 @@ import axios from 'axios'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { handleUnauthorizedResponse } from './httpAuthInterceptor'
|
||||
|
||||
// A 401 means the server has rejected the token (missing, expired, or
|
||||
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
||||
// and send the user back to login rather than leaving them on a page where
|
||||
// every request silently fails.
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('user-token')
|
||||
localStorage.removeItem('user-id')
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
handleUnauthorizedResponse
|
||||
)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
Reference in New Issue
Block a user