diff --git a/components/UI/Gallery/GalleryComponent.tsx b/components/UI/Gallery/GalleryComponent.tsx index 4436a0d6..017765ff 100644 --- a/components/UI/Gallery/GalleryComponent.tsx +++ b/components/UI/Gallery/GalleryComponent.tsx @@ -15,8 +15,13 @@ import { ThemeContext } from "../../../contexts/SettingsContexts/ThemeContext"; import GetHydraProButton from "../GetHydraProButton"; import { SubscriptionsContext } from "../../../contexts/SubscriptionsContext"; import { MediaViewerContext } from "../../../contexts/MediaViewerContext"; +import GallerySeenTracker from "./gallerySeenTracker"; type GalleryItem = { + post: Post; + postId: string; + mediaIndex: number; + mediaCount: number; mediaAspectRatio: number; } & ( | { @@ -34,6 +39,7 @@ type GalleryComponentProps = { loadMore: () => Promise; fullyLoaded: boolean; hitFilterLimit: boolean; + onPostScrolledPast?: (post: Post) => void | Promise; }; const FREE_LIMIT_POST_COUNT = 100; @@ -43,6 +49,7 @@ export default function GalleryComponent({ loadMore, fullyLoaded, hitFilterLimit, + onPostScrolledPast, }: GalleryComponentProps) { const { theme } = useContext(ThemeContext); const { isPro } = useContext(SubscriptionsContext); @@ -56,6 +63,9 @@ export default function GalleryComponent({ const [hitFreeLimit, setHitFreeLimit] = useState(false); const flashListRef = useRef>(null); + const seenTrackerRef = useRef(new GallerySeenTracker()); + const lastScrollPositionRef = useRef(0); + const scrollingForwardRef = useRef(true); /** * This looks weird, but it's because we need to be able to fetch the post data @@ -69,15 +79,25 @@ export default function GalleryComponent({ const galleryMedia: GalleryItem[] = posts.flatMap((post) => { if (post.videos.length > 0) { - return post.videos.map((video) => ({ - type: "video", + const mediaCount = post.videos.length; + return post.videos.map((video, mediaIndex) => ({ + type: "video" as const, source: video, + post, + postId: post.id, + mediaIndex, + mediaCount, mediaAspectRatio: post.mediaAspectRatio, })); } else if (post.images.length > 0) { - return post.images.map((image) => ({ - type: "image", + const mediaCount = post.images.length; + return post.images.map((image, mediaIndex) => ({ + type: "image" as const, source: image, + post, + postId: post.id, + mediaIndex, + mediaCount, mediaAspectRatio: post.mediaAspectRatio, })); } else { @@ -177,20 +197,51 @@ export default function GalleryComponent({ )} getItemType={(item) => item.type} - keyExtractor={(item, index) => - item.type === "image" - ? ((typeof item.source === "string" - ? item.source - : item.source[0].uri) ?? index.toString()) - : item.source.source.length - ? item.source.source - : index.toString() - } + keyExtractor={(item) => `${item.postId}-${item.mediaIndex}`} masonry={true} optimizeItemArrangement={true} drawDistance={200} numColumns={2} showsVerticalScrollIndicator={false} + scrollEventThrottle={100} + onScroll={ + onPostScrolledPast + ? (event) => { + const scrollPosition = event.nativeEvent.contentOffset.y; + const scrollDelta = + scrollPosition - lastScrollPositionRef.current; + if (Math.abs(scrollDelta) > 1) { + scrollingForwardRef.current = scrollDelta > 0; + } + lastScrollPositionRef.current = scrollPosition; + } + : undefined + } + onViewableItemsChanged={ + onPostScrolledPast + ? ({ changed }) => { + const changedPosts = new Map(); + for (const { item } of changed) { + changedPosts.set(item.postId, item.post); + } + + const newlySeenPostIds = seenTrackerRef.current.update( + changed.map(({ item, isViewable }) => ({ + item, + isViewable, + })), + scrollingForwardRef.current, + ); + + for (const postId of newlySeenPostIds) { + const post = changedPosts.get(postId); + if (post) { + void onPostScrolledPast(post); + } + } + } + : undefined + } onEndReachedThreshold={2} onEndReached={() => loadMoreData()} ListFooterComponent={ diff --git a/components/UI/Gallery/gallerySeenTracker.ts b/components/UI/Gallery/gallerySeenTracker.ts new file mode 100644 index 00000000..d8f73eee --- /dev/null +++ b/components/UI/Gallery/gallerySeenTracker.ts @@ -0,0 +1,87 @@ +export type GalleryViewabilityItem = { + postId: string; + mediaIndex: number; + mediaCount: number; +}; + +export type GalleryViewabilityChange = { + item: GalleryViewabilityItem; + isViewable: boolean; +}; + +/** + * Tracks gallery media visibility per post without relying on list indices. + * Masonry layout may rearrange tiles, so a post is only considered scrolled + * past after every one of its media tiles has been viewable and none remain + * visible while the user is moving forward through the feed. + */ +export default class GallerySeenTracker { + private readonly viewedMediaByPost = new Map>(); + private readonly visibleMediaByPost = new Map>(); + private readonly markedPostIds = new Set(); + + update( + changes: GalleryViewabilityChange[], + scrollingForward: boolean, + ): string[] { + const candidatePostIds = new Set(); + const mediaCountByPost = new Map(); + + for (const { item, isViewable } of changes) { + mediaCountByPost.set(item.postId, item.mediaCount); + + if (isViewable) { + this.addMedia(this.viewedMediaByPost, item.postId, item.mediaIndex); + this.addMedia(this.visibleMediaByPost, item.postId, item.mediaIndex); + } else { + this.removeMedia(this.visibleMediaByPost, item.postId, item.mediaIndex); + candidatePostIds.add(item.postId); + } + } + + if (!scrollingForward) return []; + + const newlySeenPostIds: string[] = []; + for (const postId of candidatePostIds) { + if (this.markedPostIds.has(postId)) continue; + + const mediaCount = mediaCountByPost.get(postId); + if (mediaCount === undefined) continue; + + const viewedMediaCount = this.viewedMediaByPost.get(postId)?.size ?? 0; + const visibleMediaCount = this.visibleMediaByPost.get(postId)?.size ?? 0; + if (viewedMediaCount < mediaCount || visibleMediaCount > 0) continue; + + this.markedPostIds.add(postId); + this.viewedMediaByPost.delete(postId); + this.visibleMediaByPost.delete(postId); + newlySeenPostIds.push(postId); + } + + return newlySeenPostIds; + } + + private addMedia( + mediaByPost: Map>, + postId: string, + mediaIndex: number, + ) { + const mediaIndexes = mediaByPost.get(postId) ?? new Set(); + mediaIndexes.add(mediaIndex); + mediaByPost.set(postId, mediaIndexes); + } + + private removeMedia( + mediaByPost: Map>, + postId: string, + mediaIndex: number, + ) { + const mediaIndexes = mediaByPost.get(postId); + if (!mediaIndexes) return; + + mediaIndexes.delete(mediaIndex); + if (mediaIndexes.size === 0) { + mediaByPost.delete(postId); + } + } +} diff --git a/documentation/gallery_mode.md b/documentation/gallery_mode.md index 9bf34d3a..87bf0935 100644 --- a/documentation/gallery_mode.md +++ b/documentation/gallery_mode.md @@ -29,7 +29,7 @@ The post overlay shows the title, a text preview, the subreddit, and the author. ## Filters -Gallery Mode automatically filters out non-media posts. Your text filters and "hide seen posts" settings still apply. On multireddit pages, subreddit filters are also applied. +Gallery Mode automatically filters out non-media posts. Your text filters and "hide seen posts" settings still apply. When **"Mark as Seen On Scroll"** is enabled, a post is marked as seen after all of its Gallery Mode media tiles have been viewed in the grid and scrolled past. On multireddit pages, subreddit filters are also applied. Note that AI filters do not apply in Gallery Mode. diff --git a/pages/GalleryPage.tsx b/pages/GalleryPage.tsx index aa9b2ba3..74805dd6 100644 --- a/pages/GalleryPage.tsx +++ b/pages/GalleryPage.tsx @@ -17,6 +17,7 @@ import { SubredditContext } from "../contexts/SubredditContext"; import GalleryComponent from "../components/UI/Gallery/GalleryComponent"; import { filterNonMediaItems } from "../utils/filters/filterNonMediaItems"; import AccessFailureComponent from "../components/UI/AccessFailureComponent"; +import { markPostSeen } from "../db/functions/SeenPosts"; export default function GalleryPage({ route }: StackPageProps<"GalleryPage">) { const { url } = route.params; @@ -32,8 +33,12 @@ export default function GalleryPage({ route }: StackPageProps<"GalleryPage">) { const { theme } = useContext(ThemeContext); const { subreddits } = useContext(SubredditContext); - const { filterPostsByText, filterPostsBySubreddit, getHideSeenURLStatus } = - useContext(FiltersContext); + const { + filterPostsByText, + filterPostsBySubreddit, + getHideSeenURLStatus, + autoMarkAsSeen, + } = useContext(FiltersContext); const shouldFilterSeen = getHideSeenURLStatus(url); @@ -116,6 +121,7 @@ export default function GalleryPage({ route }: StackPageProps<"GalleryPage">) { loadMore={loadMorePosts} fullyLoaded={fullyLoaded} hitFilterLimit={hitFilterLimit} + onPostScrolledPast={autoMarkAsSeen ? markPostSeen : undefined} /> diff --git a/tests/gallerySeenTracker.test.ts b/tests/gallerySeenTracker.test.ts new file mode 100644 index 00000000..99c0e69d --- /dev/null +++ b/tests/gallerySeenTracker.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; + +import GallerySeenTracker, { + type GalleryViewabilityChange, +} from "../components/UI/Gallery/gallerySeenTracker"; + +const change = ( + postId: string, + mediaIndex: number, + mediaCount: number, + isViewable: boolean, +): GalleryViewabilityChange => ({ + item: { postId, mediaIndex, mediaCount }, + isViewable, +}); + +describe("GallerySeenTracker", () => { + test("marks a single-media post after it leaves view while scrolling forward", () => { + const tracker = new GallerySeenTracker(); + + expect(tracker.update([change("a", 0, 1, true)], true)).toEqual([]); + expect(tracker.update([change("a", 0, 1, false)], true)).toEqual(["a"]); + }); + + test("waits until every media tile in an album has been viewed and left view", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("album", 0, 3, true)], true); + expect(tracker.update([change("album", 0, 3, false)], true)).toEqual([]); + + tracker.update( + [change("album", 1, 3, true), change("album", 2, 3, true)], + true, + ); + expect(tracker.update([change("album", 1, 3, false)], true)).toEqual([]); + expect(tracker.update([change("album", 2, 3, false)], true)).toEqual([ + "album", + ]); + }); + + test("handles one album tile leaving as another becomes visible", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("album", 0, 2, true)], true); + expect( + tracker.update( + [change("album", 0, 2, false), change("album", 1, 2, true)], + true, + ), + ).toEqual([]); + expect(tracker.update([change("album", 1, 2, false)], true)).toEqual([ + "album", + ]); + }); + + test("does not mark a post whose remaining album tiles were never visible", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("album", 0, 2, true)], true); + expect(tracker.update([change("album", 0, 2, false)], true)).toEqual([]); + }); + + test("does not mark when a post leaves view while scrolling backward", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("a", 0, 1, true)], false); + expect(tracker.update([change("a", 0, 1, false)], false)).toEqual([]); + + tracker.update([change("a", 0, 1, true)], true); + expect(tracker.update([change("a", 0, 1, false)], true)).toEqual(["a"]); + }); + + test("marks a post at most once despite repeated viewability callbacks", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("a", 0, 1, true), change("a", 0, 1, true)], true); + expect(tracker.update([change("a", 0, 1, false)], true)).toEqual(["a"]); + + tracker.update([change("a", 0, 1, true)], true); + expect(tracker.update([change("a", 0, 1, false)], true)).toEqual([]); + }); + + test("handles multiple posts becoming scrolled past in one update", () => { + const tracker = new GallerySeenTracker(); + + tracker.update([change("a", 0, 1, true), change("b", 0, 1, true)], true); + expect( + tracker.update( + [change("a", 0, 1, false), change("b", 0, 1, false)], + true, + ), + ).toEqual(["a", "b"]); + }); +});