diff --git a/package.json b/package.json index fb1e99fff07..818138f9d52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blacksky.community", - "version": "1.127.1", + "version": "1.128.0", "private": true, "packageManager": "pnpm@11.7.0", "engines": { diff --git a/src/state/queries/__tests__/community-timeline-sort.test.ts b/src/state/queries/__tests__/community-timeline-sort.test.ts new file mode 100644 index 00000000000..d5946fbf48b --- /dev/null +++ b/src/state/queries/__tests__/community-timeline-sort.test.ts @@ -0,0 +1,90 @@ +import {type BskyAgent} from '@atproto/api' + +jest.mock('@atproto/api', () => ({ + ...jest.requireActual('@atproto/api'), + jsonToLex: (value: unknown) => value, +})) +jest.mock('#/state/session', () => ({useAgent: jest.fn()})) +jest.mock('#/state/preferences/moderation-opts', () => ({ + useModerationOpts: jest.fn(), +})) +jest.mock('#/state/queries/preferences', () => ({ + usePreferencesQuery: jest.fn(), +})) + +import { + fetchCommunityTimelinePage, + TIMELINE_RQKEY, + TIMELINE_RQKEY_ROOT, +} from '../community-feed' + +function mockAgent() { + const fetchHandler = jest.fn, [string, RequestInit]>() + fetchHandler.mockResolvedValue( + new Response(JSON.stringify({cursor: 'next', feed: []})), + ) + return {agent: {fetchHandler} as unknown as BskyAgent, fetchHandler} +} + +function requestedParams( + fetchHandler: jest.Mock, [string, RequestInit]>, +) { + const path = fetchHandler.mock.calls[0][0] + return new URL(path, 'https://example.test').searchParams +} + +describe('fetchCommunityTimelinePage', () => { + it('sends the same request as before for the recent sort', async () => { + const {agent, fetchHandler} = mockAgent() + + await fetchCommunityTimelinePage(agent, {limit: 30, sort: 'recent'}) + + const params = requestedParams(fetchHandler) + expect(params.get('limit')).toBe('30') + expect(params.has('sort')).toBe(false) + expect(params.has('cursor')).toBe(false) + }) + + it('sends the hot sort together with its cursor', async () => { + const {agent, fetchHandler} = mockAgent() + + await fetchCommunityTimelinePage(agent, { + limit: 30, + cursor: '2026-09-03T00:00:00.000Z::123::bafycid', + sort: 'hot', + }) + + const params = requestedParams(fetchHandler) + expect(params.get('sort')).toBe('hot') + expect(params.get('cursor')).toBe('2026-09-03T00:00:00.000Z::123::bafycid') + }) + + it('returns the page cursor', async () => { + const {agent} = mockAgent() + + const page = await fetchCommunityTimelinePage(agent, { + limit: 30, + sort: 'hot', + }) + + expect(page.cursor).toBe('next') + expect(page.feed).toEqual([]) + }) + + it('throws on a non-2xx response', async () => { + const {agent, fetchHandler} = mockAgent() + fetchHandler.mockResolvedValue(new Response('{}', {status: 500})) + + await expect( + fetchCommunityTimelinePage(agent, {limit: 30, sort: 'recent'}), + ).rejects.toThrow('getCommunityTimeline failed: 500') + }) +}) + +describe('TIMELINE_RQKEY', () => { + it('keys each sort separately under the shared root', () => { + expect(TIMELINE_RQKEY()).toEqual([TIMELINE_RQKEY_ROOT, 'recent']) + expect(TIMELINE_RQKEY('hot')).toEqual([TIMELINE_RQKEY_ROOT, 'hot']) + expect(TIMELINE_RQKEY('hot')).not.toEqual(TIMELINE_RQKEY('recent')) + }) +}) diff --git a/src/state/queries/community-feed.ts b/src/state/queries/community-feed.ts index e518019e077..71b8c1f7734 100644 --- a/src/state/queries/community-feed.ts +++ b/src/state/queries/community-feed.ts @@ -36,8 +36,13 @@ type RQPageParam = string | undefined export const RQKEY_ROOT = 'community-feed' export const RQKEY = (actor: string) => [RQKEY_ROOT, actor] -const TIMELINE_RQKEY_ROOT = 'community-timeline' -export const TIMELINE_RQKEY = () => [TIMELINE_RQKEY_ROOT] +export type CommunityTimelineSort = 'recent' | 'hot' + +export const TIMELINE_RQKEY_ROOT = 'community-timeline' +export const TIMELINE_RQKEY = (sort: CommunityTimelineSort = 'recent') => [ + TIMELINE_RQKEY_ROOT, + sort, +] // Server returns feedViewPost format with hydrated posts // Support both old 'posts' format (raw) and new 'feed' format (hydrated) @@ -93,7 +98,36 @@ export function useCommunityFeedQuery(actor: string | undefined) { * Query for the global community timeline (all community posts). * Used on the Home screen Community tab. */ -export function useCommunityTimelineQuery(enabled: boolean) { +export async function fetchCommunityTimelinePage( + agent: BskyAgent, + { + limit, + cursor, + sort, + }: {limit: number; cursor?: string; sort: CommunityTimelineSort}, +) { + const params: Record = {limit: String(limit)} + if (cursor) { + params.cursor = cursor + } + if (sort !== 'recent') { + params.sort = sort + } + const res = await communityXrpc( + agent, + 'community.blacksky.feed.getCommunityTimeline', + {params}, + ) + if (!res.ok) { + throw new Error(`getCommunityTimeline failed: ${res.status}`) + } + return toSpaceFeedPage(jsonToLex(await res.json())) +} + +export function useCommunityTimelineQuery( + enabled: boolean, + sort: CommunityTimelineSort = 'recent', +) { const agent = useAgent() return useInfiniteQuery< CommunityFeedPage, @@ -102,23 +136,13 @@ export function useCommunityTimelineQuery(enabled: boolean) { QueryKey, RQPageParam >({ - queryKey: TIMELINE_RQKEY(), - async queryFn({pageParam}: {pageParam: RQPageParam}) { - const params: Record = { - limit: String(PAGE_SIZE), - } - if (pageParam) { - params.cursor = pageParam - } - const res = await communityXrpc( - agent, - 'community.blacksky.feed.getCommunityTimeline', - {params}, - ) - if (!res.ok) { - throw new Error(`getCommunityTimeline failed: ${res.status}`) - } - return toSpaceFeedPage(jsonToLex(await res.json())) + queryKey: TIMELINE_RQKEY(sort), + queryFn({pageParam}: {pageParam: RQPageParam}) { + return fetchCommunityTimelinePage(agent, { + limit: PAGE_SIZE, + cursor: pageParam, + sort, + }) }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, @@ -138,15 +162,10 @@ export function useCommunityTimelineQuery(enabled: boolean) { export async function fetchCommunityTimelineHead( agent: BskyAgent, ): Promise { - const res = await communityXrpc( - agent, - 'community.blacksky.feed.getCommunityTimeline', - {params: {limit: '10'}}, - ) - if (!res.ok) { - throw new Error(`getCommunityTimeline failed: ${res.status}`) - } - const page = toSpaceFeedPage(jsonToLex(await res.json())) + const page = await fetchCommunityTimelinePage(agent, { + limit: 10, + sort: 'recent', + }) return page.feed.find(surfacesInCommunityFeed) } diff --git a/src/state/queries/peer-mod-label.ts b/src/state/queries/peer-mod-label.ts index a9255cc07d3..43d892bfa78 100644 --- a/src/state/queries/peer-mod-label.ts +++ b/src/state/queries/peer-mod-label.ts @@ -5,7 +5,7 @@ import {communityXrpc} from '#/lib/api/community' import { COMMUNITY_POST_RQKEY, RQKEY_ROOT as COMMUNITY_FEED_RQKEY_ROOT, - TIMELINE_RQKEY, + TIMELINE_RQKEY_ROOT, } from '#/state/queries/community-feed' import {useAgent} from '#/state/session' import {BLACKSKY_LABELER} from '#/state/session/additional-moderation-authorities' @@ -133,7 +133,7 @@ function useInvalidateLabelState() { void queryClient.invalidateQueries({ queryKey: COMMUNITY_POST_RQKEY(subjectUri), }) - void queryClient.invalidateQueries({queryKey: TIMELINE_RQKEY()}) + void queryClient.invalidateQueries({queryKey: [TIMELINE_RQKEY_ROOT]}) void queryClient.invalidateQueries({ queryKey: [COMMUNITY_FEED_RQKEY_ROOT], }) diff --git a/src/storage/hooks/community-feed-sort.ts b/src/storage/hooks/community-feed-sort.ts new file mode 100644 index 00000000000..bdf1619c028 --- /dev/null +++ b/src/storage/hooks/community-feed-sort.ts @@ -0,0 +1,12 @@ +import {useSession} from '#/state/session' +import {account, useStorage} from '#/storage' + +export function useCommunityFeedSort() { + const {currentAccount} = useSession() + const [sort = 'recent', setSort] = useStorage(account, [ + currentAccount?.did ?? '', + 'communityFeedSort', + ]) + + return [sort, setSort] as const +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 2d56e2e41bd..404199f90ea 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -44,6 +44,11 @@ export type Account = { lastSelectedHomeFeed?: string + /** + * Sort order for the Home screen's Community tab. + */ + communityFeedSort?: 'recent' | 'hot' + /** * Recently selected GIFs in the GIF picker. Most recent first, capped at 20. */ diff --git a/src/view/com/feeds/CommunityFeedPage.tsx b/src/view/com/feeds/CommunityFeedPage.tsx index 4bed7e57ee2..4ea95481de7 100644 --- a/src/view/com/feeds/CommunityFeedPage.tsx +++ b/src/view/com/feeds/CommunityFeedPage.tsx @@ -14,6 +14,7 @@ import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { type CommunityFeedSlice, + type CommunityTimelineSort, fetchCommunityTimelineHead, TIMELINE_RQKEY, useCommunityFeedSlices, @@ -21,6 +22,8 @@ import { } from '#/state/queries/community-feed' import {truncateAndInvalidate} from '#/state/queries/util' import {useAgent, useSession} from '#/state/session' +import {CommunityFeedSortMenu} from '#/view/com/feeds/CommunityFeedSortMenu' +import {ComposerPrompt} from '#/view/com/feeds/ComposerPrompt' import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' import {PostFeedItem} from '#/view/com/posts/PostFeedItem' import {ViewFullThread} from '#/view/com/posts/ViewFullThread' @@ -34,6 +37,7 @@ import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig' import {Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' +import {useCommunityFeedSort} from '#/storage/hooks/community-feed-sort' type CommunityFeedRow = | { @@ -64,6 +68,7 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { openComposer({logContext: 'Fab'}) }, [openComposer]) + const [sort, setSort] = useCommunityFeedSort() const { data, isLoading, @@ -73,7 +78,7 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { fetchNextPage, isFetchingNextPage, refetch, - } = useCommunityTimelineQuery(isPageFocused) + } = useCommunityTimelineQuery(isPageFocused, sort) const feedItems = useMemo( () => data?.pages.flatMap(page => page.feed ?? []) ?? [], @@ -133,12 +138,25 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { const onRefresh = useCallback(async () => { setIsRefreshing(true) try { - await truncateAndInvalidate(queryClient, TIMELINE_RQKEY()) + await truncateAndInvalidate(queryClient, TIMELINE_RQKEY(sort)) setHasNew(false) } finally { setIsRefreshing(false) } - }, [queryClient]) + }, [queryClient, sort]) + + const onChangeSort = useCallback( + (next: CommunityTimelineSort) => { + if (next === sort) return + setSort(next) + setHasNew(false) + scrollElRef.current?.scrollToOffset({ + animated: false, + offset: -headerOffset, + }) + }, + [sort, setSort, headerOffset], + ) // Refetching an infinite query replays every loaded page, so doing it on // a timer shifts content under the user's scroll position (see upstream @@ -155,6 +173,8 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { void refetch() return } + // The head probe only means something in chronological order. + if (sort !== 'recent') return const head = await fetchCommunityTimelineHead(agent) if (!head) return if ( @@ -186,8 +206,31 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { offset: -headerOffset, }) setHasNew(false) - void truncateAndInvalidate(queryClient, TIMELINE_RQKEY()) - }, [scrollElRef, headerOffset, queryClient]) + void truncateAndInvalidate(queryClient, TIMELINE_RQKEY(sort)) + }, [scrollElRef, headerOffset, queryClient, sort]) + + const renderHeader = useCallback( + () => ( + <> + + + + + + + + + + + ), + [sort, onChangeSort, t], + ) const renderItem = useCallback( ({item, index}: ListRenderItemInfo) => { @@ -292,7 +335,7 @@ export function CommunityFeedPage({isPageFocused}: {isPageFocused: boolean}) { data={rows} renderItem={renderItem} keyExtractor={keyExtractor} - ListHeaderComponent={HomeAppviewOutageNotice} + ListHeaderComponent={renderHeader} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} onEndReached={onEndReached} diff --git a/src/view/com/feeds/CommunityFeedSortMenu.tsx b/src/view/com/feeds/CommunityFeedSortMenu.tsx new file mode 100644 index 00000000000..0592be52e1c --- /dev/null +++ b/src/view/com/feeds/CommunityFeedSortMenu.tsx @@ -0,0 +1,64 @@ +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {type CommunityTimelineSort} from '#/state/queries/community-feed' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' +import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' +import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame' +import * as Menu from '#/components/Menu' + +export function CommunityFeedSortMenu({ + sort, + onChange, +}: { + sort: CommunityTimelineSort + onChange: (sort: CommunityTimelineSort) => void +}) { + const {_} = useLingui() + const isHot = sort === 'hot' + const currentLabel = isHot ? _(msg`Hot`) : _(msg`Recent`) + + return ( + + + {({props}) => ( + + )} + + + + Sort by + + + onChange('recent')}> + + + Recent + + + + onChange('hot')}> + + + Hot + + + + + + + ) +} diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 7c40b8ce5de..54a3a5f4cb1 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -159,6 +159,7 @@ export function FeedPage({ headerOffset={headerOffset} savedFeedConfig={savedFeedConfig} isVideoFeed={isVideoFeed} + showComposerPrompt={!isVideoFeed} /> diff --git a/src/view/com/feeds/__tests__/CommunityFeedSortMenu.test.tsx b/src/view/com/feeds/__tests__/CommunityFeedSortMenu.test.tsx new file mode 100644 index 00000000000..cd2f17f64b5 --- /dev/null +++ b/src/view/com/feeds/__tests__/CommunityFeedSortMenu.test.tsx @@ -0,0 +1,147 @@ +import {fireEvent, render} from '@testing-library/react-native' + +jest.mock('@lingui/react', () => ({ + useLingui: () => ({ + _: ( + message: {message?: string; values?: Record} | string, + ) => { + if (typeof message === 'string') return message + return message.message?.replace( + /{(\w+)}/g, + (_, key: string) => message.values?.[key] ?? '', + ) + }, + }), + Trans: ({ + id, + message, + values, + }: { + id: string + message?: string + values?: Record + }) => + (message ?? id).replace( + /{(\w+)}/g, + (_, key: string) => values?.[key] ?? '', + ), +})) +jest.mock('#/components/icons/Chevron', () => ({ + ChevronBottom_Stroke2_Corner0_Rounded: () => null, +})) +jest.mock('#/components/icons/Clock', () => ({ + Clock_Stroke2_Corner0_Rounded: () => null, +})) +jest.mock('#/components/icons/Flame', () => ({ + Flame_Stroke2_Corner1_Rounded: () => null, +})) +jest.mock('#/components/Button', () => { + const React = require('react') + const {Pressable, Text} = require('react-native') + return { + Button: ({ + children, + label, + onPress, + testID, + }: { + children: React.ReactNode + label: string + onPress: () => void + testID?: string + }) => ( + + {children} + + ), + ButtonText: ({children}: {children: React.ReactNode}) => ( + {children} + ), + ButtonIcon: () => null, + } +}) +jest.mock('#/components/Menu', () => { + const {Pressable, Text, View} = require('react-native') + const passthrough = ({children}: {children: React.ReactNode}) => ( + {children} + ) + return { + Root: passthrough, + Outer: passthrough, + Group: passthrough, + Trigger: ({ + children, + label, + }: { + children: (args: { + props: {accessibilityLabel: string; onPress: () => void} + }) => React.ReactNode + label: string + }) => children({props: {accessibilityLabel: label, onPress: () => {}}}), + Item: ({ + children, + label, + onPress, + }: { + children: React.ReactNode + label: string + onPress: () => void + }) => ( + + {children} + + ), + ItemText: ({children}: {children: React.ReactNode}) => ( + {children} + ), + ItemIcon: () => null, + ItemRadio: ({selected}: {selected: boolean}) => ( + {selected ? 'selected' : 'unselected'} + ), + LabelText: ({children}: {children: React.ReactNode}) => ( + {children} + ), + } +}) + +import {CommunityFeedSortMenu} from '../CommunityFeedSortMenu' + +describe('CommunityFeedSortMenu', () => { + it('shows the active sort on the trigger', () => { + const {getByTestId} = render( + , + ) + + expect(getByTestId('communityFeedSortButton')).toHaveTextContent('Hot') + }) + + it('defaults the trigger label to Recent', () => { + const {getByTestId} = render( + , + ) + + expect(getByTestId('communityFeedSortButton')).toHaveTextContent('Recent') + }) + + it('selects the other sort from the menu', () => { + const onChange = jest.fn() + const {getByTestId, getAllByTestId} = render( + , + ) + + fireEvent.press(getByTestId('Hot')) + + expect(onChange).toHaveBeenCalledWith('hot') + const radios = getAllByTestId('radio') + expect(radios[0]).toHaveTextContent(/^selected$/) + expect(radios[1]).toHaveTextContent(/^unselected$/) + }) +}) diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 7aefadde013..3d658782237 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -200,6 +200,7 @@ let PostFeed = ({ savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, + showComposerPrompt = false, }: { feed: FeedDescriptor feedParams?: FeedParams @@ -222,6 +223,7 @@ let PostFeed = ({ savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean + showComposerPrompt?: boolean lastFetchDate?: () => number }): React.ReactNode => { const ax = useAnalytics() @@ -510,12 +512,7 @@ let PostFeed = ({ type: 'liveEventFeedsAndTrendingBanner', key: 'liveEventFeedsAndTrendingBanner-' + sliceIndex, }) - // Show composer prompt for Discover and Following feeds - if ( - hasSession && - (feedUriOrActorDid === DISCOVER_FEED_URI || - feed === 'following') - ) { + if (hasSession && showComposerPrompt) { arr.push({ type: 'composerPrompt', key: 'composerPrompt-' + sliceIndex,