diff --git a/src/reader/sync.rs b/src/reader/sync.rs index 95fbca1..bed19dc 100644 --- a/src/reader/sync.rs +++ b/src/reader/sync.rs @@ -12,7 +12,7 @@ use crate::{ }, }; use actix_web::{web, HttpRequest, HttpResponse, Responder}; -use chrono::{DateTime, Duration, Local, NaiveDateTime}; +use chrono::{DateTime, Local, NaiveDateTime}; use dateparser::parse; use diesel::prelude::*; use rss::Item; @@ -127,28 +127,9 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { } } -// Only read items are purged, and only once they're old — unread items are -// kept regardless of age so infrequently-updated feeds (or infrequent syncs) -// don't lose articles the user hasn't seen yet. Items without a `created_ts` -// (e.g. ones inserted before this column existed, or whose feed didn't -// provide a publish date) are left alone — `lt` never matches NULL. -fn delete_old_feed_items(connection: &mut PgConnection) { - let cutoff = Local::now().naive_local() - Duration::days(14); - let result = diesel::delete( - feed_item::table - .filter(feed_item::read.eq(true)) - .filter(feed_item::created_ts.lt(cutoff)), - ) - .execute(connection); - - log::info!("Deleted old read feed items (older than 2 weeks): {:?}", result); -} - pub async fn sync(_req: HttpRequest, data: web::Json) -> impl Responder { let mut connection: diesel::PgConnection = establish_connection(); - delete_old_feed_items(&mut connection); - let req_user_id: i32 = data.user_id; let feeds: Vec = feed::table @@ -183,6 +164,7 @@ mod tests { use crate::models::user::rss_user::User; use crate::schema::users; use crate::test_helpers::unique_suffix; + use chrono::Duration; use super::*; @@ -270,109 +252,6 @@ mod tests { ); } - #[actix_web::test] - async fn delete_old_feed_items_removes_only_old_read_items() { - let mut connection = establish_connection(); - let suffix = unique_suffix(); - - let new_user = NewUser::new( - format!("cleanup_test_{suffix}"), - format!("cleanup_{suffix}@example.test"), - "secret".to_string(), - ); - let user: User = diesel::insert_into(users::table) - .values(&new_user) - .get_result(&mut connection) - .unwrap(); - - let new_feed = NewFeed::new( - format!("Cleanup test feed {suffix}"), - format!("https://example.test/feed/{suffix}"), - user.id, - ); - let feed: Feed = diesel::insert_into(feed::table) - .values(&new_feed) - .get_result(&mut connection) - .unwrap(); - - let now = Local::now().naive_local(); - let old_read_item = NewFeedItem::new( - feed.id, - "old read content".to_string(), - format!("Old read article {suffix}"), - format!("https://example.test/article/old-read-{suffix}"), - Some(now - Duration::days(20)), - ); - let old_unread_item = NewFeedItem::new( - feed.id, - "old unread content".to_string(), - format!("Old unread article {suffix}"), - format!("https://example.test/article/old-unread-{suffix}"), - Some(now - Duration::days(20)), - ); - let recent_read_item = NewFeedItem::new( - feed.id, - "recent content".to_string(), - format!("Recent article {suffix}"), - format!("https://example.test/article/recent-{suffix}"), - Some(now - Duration::days(1)), - ); - - let old_read: FeedItem = diesel::insert_into(feed_item::table) - .values(&old_read_item) - .get_result(&mut connection) - .unwrap(); - diesel::update(&old_read) - .set(feed_item::read.eq(true)) - .execute(&mut connection) - .unwrap(); - - let old_unread: FeedItem = diesel::insert_into(feed_item::table) - .values(&old_unread_item) - .get_result(&mut connection) - .unwrap(); - - let recent: FeedItem = diesel::insert_into(feed_item::table) - .values(&recent_read_item) - .get_result(&mut connection) - .unwrap(); - diesel::update(&recent) - .set(feed_item::read.eq(true)) - .execute(&mut connection) - .unwrap(); - - delete_old_feed_items(&mut connection); - - let remaining: Vec = feed_item::table - .filter(feed_id.eq(feed.id)) - .load(&mut connection) - .unwrap(); - - let remaining_ids: Vec = remaining.iter().map(|item| item.id).collect(); - assert!( - !remaining_ids.contains(&old_read.id), - "old read item should have been deleted" - ); - assert!( - remaining_ids.contains(&old_unread.id), - "old unread item should be kept" - ); - assert!( - remaining_ids.contains(&recent.id), - "recent item should be kept" - ); - - diesel::delete(feed_item::table.filter(feed_id.eq(feed.id))) - .execute(&mut connection) - .ok(); - diesel::delete(feed::table.filter(feed::id.eq(feed.id))) - .execute(&mut connection) - .ok(); - diesel::delete(users::table.filter(users::id.eq(user.id))) - .execute(&mut connection) - .ok(); - } - #[actix_web::test] async fn create_feed_item_inserts_articles_older_than_two_weeks() { let mut connection = establish_connection(); diff --git a/vue/src/composables/__tests__/useFeeds.spec.js b/vue/src/composables/__tests__/useFeeds.spec.js index ded94fe..a4fab61 100644 --- a/vue/src/composables/__tests__/useFeeds.spec.js +++ b/vue/src/composables/__tests__/useFeeds.spec.js @@ -12,7 +12,7 @@ class FakeIntersectionObserver { vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) describe('useFeeds', () => { - const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds() + const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds() beforeEach(() => { localStorage.setItem('user-token', 'test-token') @@ -91,6 +91,31 @@ describe('useFeeds', () => { expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything()) }) + it('marks the correct articles read when several scroll out of view in one batch', async () => { + feeds.value = [ + { id: 101, title: 'First' }, + { id: 102, title: 'Second' }, + { id: 103, title: 'Third' }, + ] + setInitialLoad(true) + axios.put.mockResolvedValue({ status: 200 }) + + // Both the first and second articles scrolled above the viewport in the + // same IntersectionObserver callback — their `target.id` reflects their + // original render-time indices (0 and 1). + await handleIntersection([ + { isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } }, + { isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } }, + ]) + + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/101', null, expect.anything()) + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/102', null, expect.anything()) + expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/103', null, expect.anything()) + expect(feeds.value).toEqual([{ id: 103, title: 'Third' }]) + + setInitialLoad(false) + }) + it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => { feeds.value = [{ id: 1, diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index f662d07..36824f5 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -1,4 +1,4 @@ -import { ref, unref, nextTick } from 'vue'; +import { ref, nextTick } from 'vue'; import axios from 'axios'; import { Readability } from '@mozilla/readability'; @@ -155,21 +155,25 @@ function setupIntersectionObserver() { } async function handleIntersection(entries) { - // The callback function for when the target element enters or exits the viewport - for (const entry of entries) { - // An article that has scrolled above the viewport (not intersecting, - // bounding box above the top edge) has been read — mark it and remove it. - if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) { - await markRead(feeds.value[entry.target.id].id) - removeFeed(entry.target.id) - document.getElementById(0)?.scrollIntoView() - } - } -} + // An article that has scrolled above the viewport (not intersecting, + // bounding box above the top edge) has been read. Resolve all affected + // feeds up front, before any removal — splicing `feeds` while iterating + // would shift the array indices that later entries' `target.id` refer to, + // causing the wrong item to be marked read and removed. + const readFeeds = entries + .filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) + .map(entry => feeds.value[entry.target.id]) + .filter(Boolean) -function removeFeed(index) { - const array = unref(feeds); - array.splice(index, 1); + if (readFeeds.length === 0) return + + for (const feed of readFeeds) { + await markRead(feed.id) + } + + const readIds = new Set(readFeeds.map(feed => feed.id)) + feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) + document.getElementById(0)?.scrollIntoView() } function setInitialLoad(value) { @@ -263,7 +267,7 @@ export function useFeeds() { markAllRead, showMessageForXSeconds, setupIntersectionObserver, - removeFeed, setInitialLoad, + handleIntersection, } }