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>
This commit is contained in:
parent
f9944b55e2
commit
f57a8cf8ba
87 changed files with 4090 additions and 1988 deletions
|
@ -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': {}
|
||||
|
|
45
src/lib/api/feed/list.ts
Normal file
45
src/lib/api/feed/list.ts
Normal file
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
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: []}}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
25
src/lib/async/accumulate.ts
Normal file
25
src/lib/async/accumulate.ts
Normal file
|
@ -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
|
||||
}
|
24
src/lib/async/until.ts
Normal file
24
src/lib/async/until.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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])
|
||||
|
|
51
src/lib/hooks/useDesktopRightNavItems.ts
Normal file
51
src/lib/hooks/useDesktopRightNavItems.ts
Normal file
|
@ -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
|
||||
}
|
29
src/lib/hooks/useHomeTabs.ts
Normal file
29
src/lib/hooks/useHomeTabs.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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('/')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue