Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 64 additions & 13 deletions components/UI/Gallery/GalleryComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
} & (
| {
Expand All @@ -34,6 +39,7 @@ type GalleryComponentProps = {
loadMore: () => Promise<void>;
fullyLoaded: boolean;
hitFilterLimit: boolean;
onPostScrolledPast?: (post: Post) => void | Promise<void>;
};

const FREE_LIMIT_POST_COUNT = 100;
Expand All @@ -43,6 +49,7 @@ export default function GalleryComponent({
loadMore,
fullyLoaded,
hitFilterLimit,
onPostScrolledPast,
}: GalleryComponentProps) {
const { theme } = useContext(ThemeContext);
const { isPro } = useContext(SubscriptionsContext);
Expand All @@ -56,6 +63,9 @@ export default function GalleryComponent({
const [hitFreeLimit, setHitFreeLimit] = useState(false);

const flashListRef = useRef<FlashListRef<GalleryItem>>(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
Expand All @@ -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 {
Expand Down Expand Up @@ -177,20 +197,51 @@ export default function GalleryComponent({
</Touchable>
)}
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<string, Post>();
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={
Expand Down
87 changes: 87 additions & 0 deletions components/UI/Gallery/gallerySeenTracker.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<number>>();
private readonly visibleMediaByPost = new Map<string, Set<number>>();
private readonly markedPostIds = new Set<string>();

update(
changes: GalleryViewabilityChange[],
scrollingForward: boolean,
): string[] {
const candidatePostIds = new Set<string>();
const mediaCountByPost = new Map<string, number>();

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<string, Set<number>>,
postId: string,
mediaIndex: number,
) {
const mediaIndexes = mediaByPost.get(postId) ?? new Set<number>();
mediaIndexes.add(mediaIndex);
mediaByPost.set(postId, mediaIndexes);
}

private removeMedia(
mediaByPost: Map<string, Set<number>>,
postId: string,
mediaIndex: number,
) {
const mediaIndexes = mediaByPost.get(postId);
if (!mediaIndexes) return;

mediaIndexes.delete(mediaIndex);
if (mediaIndexes.size === 0) {
mediaByPost.delete(postId);
}
}
}
2 changes: 1 addition & 1 deletion documentation/gallery_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions pages/GalleryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -116,6 +121,7 @@ export default function GalleryPage({ route }: StackPageProps<"GalleryPage">) {
loadMore={loadMorePosts}
fullyLoaded={fullyLoaded}
hitFilterLimit={hitFilterLimit}
onPostScrolledPast={autoMarkAsSeen ? markPostSeen : undefined}
/>
</AccessFailureComponent>
</View>
Expand Down
94 changes: 94 additions & 0 deletions tests/gallerySeenTracker.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});