diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-01 16:15:40 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-01 16:15:40 -0700 |
commit | f57a8cf8ba0cd10a54abf35d960d8fb90266fa6b (patch) | |
tree | a9da6032bcbd587d92fd1030e698aea2dbef9f72 /src/lib | |
parent | f9944b55e26fe6109bc2e7a25b88979111470ed9 (diff) | |
download | voidsky-f57a8cf8ba0cd10a54abf35d960d8fb90266fa6b.tar.zst |
Lists updates: curate lists and blocklists (#1689)
* Add lists screen * Update Lists screen and List create/edit modal to support curate lists * Rework the ProfileList screen and add curatelist support * More ProfileList progress * Update list modals * Rename mutelists to modlists * Layout updates/fixes * More layout fixes * Modal fixes * List list screen updates * Update feed page to give more info * Layout fixes to ListAddUser modal * Layout fixes to FlatList and Feed on desktop * Layout fix to LoadLatestBtn on Web * Handle did resolution before showing the ProfileList screen * Rename the CustomFeed routes to ProfileFeed for consistency * Fix layout issues with the pager and feeds * Factor out some common code * Fix UIs for mobile * Fix user list rendering * Fix: dont bubble custom feed errors in the merge feed * Refactor feed models to reduce usage of the SavedFeeds model * Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists * Add the ability to pin lists * Add pinned lists to mobile * Remove dead code * Rework the ProfileScreenHeader to create more real-estate for action buttons * Improve layout behavior on web mobile breakpoints * Refactor feed & list pages to use new Tabs layout component * Refactor to ProfileSubpageHeader * Implement modlist block and mute * Switch to new api and just modify state on modlist actions * Fix some UI overflows * Fix: dont show edit buttons on lists you dont own * Fix alignment issue on long titles * Improve loading and error states for feeds & lists * Update list dropdown icons for ios * Fetch feed display names in the mergefeed * Improve rendering off offline feeds in the feed-listing page * Update Feeds listing UI to react to changes in saved/pinned state * Refresh list and feed on posts tab press * Fix pinned feed ordering UI * Fixes to list pinning * Remove view=simple qp * Add list to feed tuners * Render richtext * Add list href * Add 'view avatar' * Remove unused import * Fix missing import * Correctly reflect block by list state * Replace the <Tabs> component with the more effective <PagerWithHeader> component * Improve the responsiveness of the PagerWithHeader * Fix visual jank in the feed loading state * Improve performance of the PagerWithHeader * Fix a case that would cause the header to animate too aggressively * Add the ability to scroll to top by tapping the selected tab * Fix unit test runner * Update modlists test * Add curatelist tests * Fix: remove link behavior in ListAddUser modal * Fix some layout jank in the PagerWithHeader on iOS * Simplify ListItems header rendering * Wait for the appview to recognize the list before proceeding with list creation * Fix glitch in the onPageSelecting index of the Pager * Fix until() * Copy fix Co-authored-by: Eric Bailey <git@esb.lol> --------- Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/analytics/types.ts | 11 | ||||
-rw-r--r-- | src/lib/api/feed/list.ts | 45 | ||||
-rw-r--r-- | src/lib/api/feed/merge.ts | 74 | ||||
-rw-r--r-- | src/lib/async/accumulate.ts | 25 | ||||
-rw-r--r-- | src/lib/async/until.ts | 24 | ||||
-rw-r--r-- | src/lib/hooks/useCustomFeed.ts | 21 | ||||
-rw-r--r-- | src/lib/hooks/useDesktopRightNavItems.ts | 51 | ||||
-rw-r--r-- | src/lib/hooks/useHomeTabs.ts | 29 | ||||
-rw-r--r-- | src/lib/icons.tsx | 27 | ||||
-rw-r--r-- | src/lib/moderation.ts | 15 | ||||
-rw-r--r-- | src/lib/routes/links.ts | 12 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 7 |
12 files changed, 283 insertions, 58 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index d10475908..b2c6f15d6 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -97,10 +97,13 @@ interface TrackPropertiesMap { // LISTS events 'Lists:onRefresh': {} 'Lists:onEndReached': {} - 'CreateMuteList:AvatarSelected': {} - 'CreateMuteList:Save': {} // CAN BE SERVER - 'Lists:Subscribe': {} // CAN BE SERVER - 'Lists:Unsubscribe': {} // CAN BE SERVER + 'CreateList:AvatarSelected': {} + 'CreateList:SaveCurateList': {} // CAN BE SERVER + 'CreateList:SaveModList': {} // CAN BE SERVER + 'Lists:Mute': {} // CAN BE SERVER + 'Lists:Unmute': {} // CAN BE SERVER + 'Lists:Block': {} // CAN BE SERVER + 'Lists:Unblock': {} // CAN BE SERVER // CUSTOM FEED events 'CustomFeed:Save': {} 'CustomFeed:Unsave': {} diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts new file mode 100644 index 000000000..e58494675 --- /dev/null +++ b/src/lib/api/feed/list.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetListFeed as GetListFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class ListFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetListFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 31e27fece..e0fbcecd8 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -114,13 +114,8 @@ export class MergeFeedAPI implements FeedAPI { } if (this.customFeeds.length === 0) { this.customFeeds = shuffle( - this.rootStore.me.savedFeeds.all.map( - feed => - new MergeFeedSource_Custom( - this.rootStore, - feed.uri, - feed.displayName, - ), + this.rootStore.preferences.savedFeeds.map( + feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), ), ) } @@ -213,43 +208,56 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor( - public rootStore: RootStoreModel, - public feedUri: string, - public feedDisplayName: string, - ) { + constructor(public rootStore: RootStoreModel, public feedUri: string) { super(rootStore) this.sourceInfo = { - displayName: feedDisplayName, + displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) + this.rootStore.agent.app.bsky.feed + .getFeedGenerator({ + feed: feedUri, + }) + .then( + res => { + if (this.sourceInfo) { + this.sourceInfo.displayName = res.data.view.displayName + } + }, + _err => {}, + ) } protected async _getFeed( cursor: string | undefined, limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ - cursor, - limit, - feed: this.feedUri, - }) - // NOTE - // some custom feeds fail to enforce the pagination limit - // so we manually truncate here - // -prf - if (limit && res.data.feed.length > limit) { - res.data.feed = res.data.feed.slice(0, limit) - } - // filter out older posts - res.data.feed = res.data.feed.filter( - post => new Date(post.post.indexedAt) > this.minDate, - ) - // attach source info - for (const post of res.data.feed) { - post.__source = this.sourceInfo + try { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + cursor, + limit, + feed: this.feedUri, + }) + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (limit && res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + // filter out older posts + res.data.feed = res.data.feed.filter( + post => new Date(post.post.indexedAt) > this.minDate, + ) + // attach source info + for (const post of res.data.feed) { + post.__source = this.sourceInfo + } + return res + } catch { + // dont bubble custom-feed errors + return {success: false, headers: {}, data: {feed: []}} } - return res } } diff --git a/src/lib/async/accumulate.ts b/src/lib/async/accumulate.ts new file mode 100644 index 000000000..99226418e --- /dev/null +++ b/src/lib/async/accumulate.ts @@ -0,0 +1,25 @@ +export interface AccumulateResponse<T> { + cursor?: string + items: T[] +} + +export type AccumulateFetchFn<T> = ( + cursor: string | undefined, +) => Promise<AccumulateResponse<T>> + +export async function accumulate<T>( + fn: AccumulateFetchFn<T>, + pageLimit = 100, +): Promise<T[]> { + let cursor: string | undefined + let acc: T[] = [] + for (let i = 0; i < pageLimit; i++) { + const res = await fn(cursor) + cursor = res.cursor + acc = acc.concat(res.items) + if (!cursor) { + break + } + } + return acc +} diff --git a/src/lib/async/until.ts b/src/lib/async/until.ts new file mode 100644 index 000000000..db53c9218 --- /dev/null +++ b/src/lib/async/until.ts @@ -0,0 +1,24 @@ +import {timeout} from './timeout' + +export async function until( + retries: number, + delay: number, + cond: (v: any, err: any) => boolean, + fn: () => Promise<any>, +): Promise<boolean> { + while (retries > 0) { + try { + const v = await fn() + if (cond(v, undefined)) { + return true + } + } catch (e: any) { + if (cond(undefined, e)) { + return true + } + } + await timeout(delay) + retries-- + } + return false +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts index d7a27050d..04201b9a1 100644 --- a/src/lib/hooks/useCustomFeed.ts +++ b/src/lib/hooks/useCustomFeed.ts @@ -1,24 +1,15 @@ import {useEffect, useState} from 'react' import {useStores} from 'state/index' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' -export function useCustomFeed(uri: string): CustomFeedModel | undefined { +export function useCustomFeed(uri: string): FeedSourceModel | undefined { const store = useStores() - const [item, setItem] = useState<CustomFeedModel | undefined>() + const [item, setItem] = useState<FeedSourceModel | undefined>() useEffect(() => { - async function fetchView() { - const res = await store.agent.app.bsky.feed.getFeedGenerator({ - feed: uri, - }) - const view = res.data.view - return view - } async function buildFeedItem() { - const view = await fetchView() - if (view) { - const temp = new CustomFeedModel(store, view) - setItem(temp) - } + const model = new FeedSourceModel(store, uri) + await model.setup() + setItem(model) } buildFeedItem() }, [store, uri]) diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts new file mode 100644 index 000000000..f27efd28f --- /dev/null +++ b/src/lib/hooks/useDesktopRightNavItems.ts @@ -0,0 +1,51 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {AtUri} from '@atproto/api' +import {FeedSourceModel} from 'state/models/content/feed-source' + +interface RightNavItem { + uri: string + href: string + hostname: string + collection: string + rkey: string + displayName: string +} + +export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { + const store = useStores() + const [items, setItems] = useState<RightNavItem[]>([]) + const [lastUris, setLastUris] = useState<string[]>([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setItems( + models.map(model => { + const {hostname, collection, rkey} = new AtUri(model.uri) + return { + uri: model.uri, + href: model.href, + hostname, + collection, + rkey, + displayName: model.displayName, + } + }), + ) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setItems]) + + return items +} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts new file mode 100644 index 000000000..69183e627 --- /dev/null +++ b/src/lib/hooks/useHomeTabs.ts @@ -0,0 +1,29 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {FeedSourceModel} from 'state/models/content/feed-source' + +export function useHomeTabs(uris: string[]): string[] { + const store = useStores() + const [tabs, setTabs] = useState<string[]>(['Following']) + const [lastUris, setLastUris] = useState<string[]>([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setTabs(['Following'].concat(models.map(f => f.displayName))) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setTabs]) + + return tabs +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index fef7be2f3..7ae88806f 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -947,3 +947,30 @@ export function ShieldExclamation({ </Svg> ) } + +export function ListIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1.5} + stroke="currentColor" + width={size} + height={size} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" + /> + </Svg> + ) +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index aadee0e74..6c08606ee 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -17,9 +17,18 @@ export function describeModerationCause( } } if (cause.type === 'blocking') { - return { - name: 'User Blocked', - description: 'You have blocked this user. You cannot view their content.', + if (cause.source.type === 'list') { + return { + name: `User Blocked by "${cause.source.list.name}"`, + description: + 'You have blocked this user. You cannot view their content.', + } + } else { + return { + name: 'User Blocked', + description: + 'You have blocked this user. You cannot view their content.', + } } } if (cause.type === 'blocked-by') { diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index cc543b6b7..397a5916c 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -13,3 +13,15 @@ export function makeProfileLink( ...segments, ].join('/') } + +export function makeCustomFeedLink( + did: string, + rkey: string, + ...segments: string[] +) { + return [`/profile`, did, 'feed', rkey, ...segments].join('/') +} + +export function makeListLink(did: string, rkey: string, ...segments: string[]) { + return [`/profile`, did, 'lists', rkey, ...segments].join('/') +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 35a379d48..c157c0ab3 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { NotFound: undefined + Lists: undefined Moderation: undefined - ModerationMuteLists: undefined + ModerationModlists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined Settings: undefined @@ -18,8 +19,8 @@ export type CommonNavigatorParams = { PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} - CustomFeed: {name: string; rkey: string} - CustomFeedLikedBy: {name: string; rkey: string} + ProfileFeed: {name: string; rkey: string} + ProfileFeedLikedBy: {name: string; rkey: string} Debug: undefined Log: undefined Support: undefined |