diff --git a/package.json b/package.json
index 90d12e773b..faa515749e 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"lodash.isequal": "^4.5.0",
"lodash.omit": "^4.5.0",
"lodash.once": "^4.1.1",
+ "lodash.random": "^3.2.0",
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
@@ -168,6 +169,7 @@
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.omit": "^4.5.7",
"@types/lodash.once": "^4.1.7",
+ "@types/lodash.random": "^3.2.7",
"@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index c16ff3a8cc..9bf6ba9818 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -40,7 +40,6 @@ import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications'
import {ModerationScreen} from './view/screens/Moderation'
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
-import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
import {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings'
import {ProfileScreen} from './view/screens/Profile'
@@ -113,11 +112,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
component={ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
/>
-
'__source' in item && !!item.__source)
+ ?.__source as FeedSourceInfo
+ }
+
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
@@ -91,6 +97,23 @@ export class FeedViewPostsSlice {
}
}
}
+
+ isFollowingAllAuthors(userDid: string) {
+ const item = this.rootItem
+ if (item.post.author.did === userDid) {
+ return true
+ }
+ if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
+ const parent = item.reply?.parent
+ if (parent?.author.did === userDid) {
+ return true
+ }
+ return (
+ parent?.author.viewer?.following && item.post.author.viewer?.following
+ )
+ }
+ return false
+ }
}
export class FeedTuner {
@@ -222,20 +245,34 @@ export class FeedTuner {
return slices
}
- static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
+ static thresholdRepliesOnly({
+ userDid,
+ minLikes,
+ followedOnly,
+ }: {
+ userDid: string
+ minLikes: number
+ followedOnly: boolean
+ }) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
- // remove any replies without at least repliesThreshold likes
+ // remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
- if (slices[i].isFullThread || !slices[i].isReply) {
+ const slice = slices[i]
+ if (slice.isFullThread || !slice.isReply) {
continue
}
- const item = slices[i].rootItem
+ const item = slice.rootItem
const isRepost = Boolean(item.reason)
- if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
+ if (isRepost) {
+ continue
+ }
+ if ((item.post.likeCount || 0) < minLikes) {
+ slices.splice(i, 1)
+ } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
}
}
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
new file mode 100644
index 0000000000..1ae925123c
--- /dev/null
+++ b/src/lib/api/feed/author.ts
@@ -0,0 +1,45 @@
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class AuthorFeedAPI implements FeedAPI {
+ cursor: string | undefined
+
+ constructor(
+ public rootStore: RootStoreModel,
+ public params: GetAuthorFeed.QueryParams,
+ ) {}
+
+ reset() {
+ this.cursor = undefined
+ }
+
+ async peekLatest(): Promise {
+ const res = await this.rootStore.agent.getAuthorFeed({
+ ...this.params,
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ const res = await this.rootStore.agent.getAuthorFeed({
+ ...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/custom.ts b/src/lib/api/feed/custom.ts
new file mode 100644
index 0000000000..d05d5acd6b
--- /dev/null
+++ b/src/lib/api/feed/custom.ts
@@ -0,0 +1,52 @@
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedGetFeed as GetCustomFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class CustomFeedAPI implements FeedAPI {
+ cursor: string | undefined
+
+ constructor(
+ public rootStore: RootStoreModel,
+ public params: GetCustomFeed.QueryParams,
+ ) {}
+
+ reset() {
+ this.cursor = undefined
+ }
+
+ async peekLatest(): Promise {
+ const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+ ...this.params,
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+ ...this.params,
+ cursor: this.cursor,
+ limit,
+ })
+ if (res.success) {
+ this.cursor = res.data.cursor
+ // NOTE
+ // some custom feeds fail to enforce the pagination limit
+ // so we manually truncate here
+ // -prf
+ if (res.data.feed.length > limit) {
+ res.data.feed = res.data.feed.slice(0, limit)
+ }
+ return {
+ cursor: res.data.cursor,
+ feed: res.data.feed,
+ }
+ }
+ return {
+ feed: [],
+ }
+ }
+}
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
new file mode 100644
index 0000000000..f14807a576
--- /dev/null
+++ b/src/lib/api/feed/following.ts
@@ -0,0 +1,37 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class FollowingFeedAPI implements FeedAPI {
+ cursor: string | undefined
+
+ constructor(public rootStore: RootStoreModel) {}
+
+ reset() {
+ this.cursor = undefined
+ }
+
+ async peekLatest(): Promise {
+ const res = await this.rootStore.agent.getTimeline({
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ const res = await this.rootStore.agent.getTimeline({
+ 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/likes.ts b/src/lib/api/feed/likes.ts
new file mode 100644
index 0000000000..e9bb14b0b1
--- /dev/null
+++ b/src/lib/api/feed/likes.ts
@@ -0,0 +1,45 @@
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedGetActorLikes as GetActorLikes,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class LikesFeedAPI implements FeedAPI {
+ cursor: string | undefined
+
+ constructor(
+ public rootStore: RootStoreModel,
+ public params: GetActorLikes.QueryParams,
+ ) {}
+
+ reset() {
+ this.cursor = undefined
+ }
+
+ async peekLatest(): Promise {
+ const res = await this.rootStore.agent.getActorLikes({
+ ...this.params,
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ const res = await this.rootStore.agent.getActorLikes({
+ ...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
new file mode 100644
index 0000000000..51a619589c
--- /dev/null
+++ b/src/lib/api/feed/merge.ts
@@ -0,0 +1,236 @@
+import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
+import {RootStoreModel} from 'state/index'
+import {timeout} from 'lib/async/timeout'
+import {bundleAsync} from 'lib/async/bundle'
+import {feedUriToHref} from 'lib/strings/url-helpers'
+import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
+
+const REQUEST_WAIT_MS = 500 // 500ms
+const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
+
+export class MergeFeedAPI implements FeedAPI {
+ following: MergeFeedSource_Following
+ customFeeds: MergeFeedSource_Custom[] = []
+ feedCursor = 0
+ itemCursor = 0
+ sampleCursor = 0
+
+ constructor(public rootStore: RootStoreModel) {
+ this.following = new MergeFeedSource_Following(this.rootStore)
+ }
+
+ reset() {
+ this.following = new MergeFeedSource_Following(this.rootStore)
+ this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
+ this.feedCursor = 0
+ this.itemCursor = 0
+ this.sampleCursor = 0
+ }
+
+ async peekLatest(): Promise {
+ const res = await this.rootStore.agent.getTimeline({
+ limit: 1,
+ })
+ return res.data.feed[0]
+ }
+
+ async fetchNext({limit}: {limit: number}): Promise {
+ // we capture here to ensure the data has loaded
+ this._captureFeedsIfNeeded()
+
+ const promises = []
+
+ // always keep following topped up
+ if (this.following.numReady < limit) {
+ promises.push(this.following.fetchNext(30))
+ }
+
+ // pick the next feeds to sample from
+ const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3)
+ this.feedCursor += 3
+ if (this.feedCursor > this.customFeeds.length) {
+ this.feedCursor = 0
+ }
+
+ // top up the feeds
+ for (const feed of feeds) {
+ if (feed.numReady < 5) {
+ promises.push(feed.fetchNext(10))
+ }
+ }
+
+ // wait for requests (all capped at a fixed timeout)
+ await Promise.all(promises)
+
+ // assemble a response by sampling from feeds with content
+ const posts: AppBskyFeedDefs.FeedViewPost[] = []
+ while (posts.length < limit) {
+ let slice = this.sampleItem()
+ if (slice[0]) {
+ posts.push(slice[0])
+ } else {
+ break
+ }
+ }
+
+ return {
+ cursor: posts.length ? 'fake' : undefined,
+ feed: posts,
+ }
+ }
+
+ sampleItem() {
+ const i = this.itemCursor++
+ const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
+ const canSample = candidateFeeds.length > 0
+ const hasFollows = this.following.numReady > 0
+
+ // this condition establishes the frequency that custom feeds are woven into follows
+ const shouldSample =
+ i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0)
+
+ if (!canSample && !hasFollows) {
+ // no data available
+ return []
+ }
+ if (shouldSample || !hasFollows) {
+ // time to sample, or the user isnt following anybody
+ return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
+ }
+ // not time to sample
+ return this.following.take(1)
+ }
+
+ _captureFeedsIfNeeded() {
+ if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) {
+ return
+ }
+ if (this.customFeeds.length === 0) {
+ this.customFeeds = shuffle(
+ this.rootStore.me.savedFeeds.all.map(
+ feed =>
+ new MergeFeedSource_Custom(
+ this.rootStore,
+ feed.uri,
+ feed.displayName,
+ ),
+ ),
+ )
+ }
+ }
+}
+
+class MergeFeedSource {
+ sourceInfo: FeedSourceInfo | undefined
+ cursor: string | undefined = undefined
+ queue: AppBskyFeedDefs.FeedViewPost[] = []
+ hasMore = true
+
+ constructor(public rootStore: RootStoreModel) {}
+
+ get numReady() {
+ return this.queue.length
+ }
+
+ get needsFetch() {
+ return this.hasMore && this.queue.length === 0
+ }
+
+ reset() {
+ this.cursor = undefined
+ this.queue = []
+ this.hasMore = true
+ }
+
+ take(n: number): AppBskyFeedDefs.FeedViewPost[] {
+ return this.queue.splice(0, n)
+ }
+
+ async fetchNext(n: number) {
+ await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)])
+ }
+
+ _fetchNextInner = bundleAsync(async (n: number) => {
+ const res = await this._getFeed(this.cursor, n)
+ if (res.success) {
+ this.cursor = res.data.cursor
+ if (res.data.feed.length) {
+ this.queue = this.queue.concat(res.data.feed)
+ } else {
+ this.hasMore = false
+ }
+ } else {
+ this.hasMore = false
+ }
+ })
+
+ protected _getFeed(
+ _cursor: string | undefined,
+ _limit: number,
+ ): Promise {
+ throw new Error('Must be overridden')
+ }
+}
+
+class MergeFeedSource_Following extends MergeFeedSource {
+ async fetchNext(n: number) {
+ return this._fetchNextInner(n)
+ }
+
+ protected async _getFeed(
+ cursor: string | undefined,
+ limit: number,
+ ): Promise {
+ const res = await this.rootStore.agent.getTimeline({cursor, limit})
+ // filter out mutes pre-emptively to ensure better mixing
+ res.data.feed = res.data.feed.filter(
+ post => !post.post.author.viewer?.muted,
+ )
+ return res
+ }
+}
+
+class MergeFeedSource_Custom extends MergeFeedSource {
+ minDate: Date
+
+ constructor(
+ public rootStore: RootStoreModel,
+ public feedUri: string,
+ public feedDisplayName: string,
+ ) {
+ super(rootStore)
+ this.sourceInfo = {
+ displayName: feedDisplayName,
+ uri: feedUriToHref(feedUri),
+ }
+ this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
+ }
+
+ protected async _getFeed(
+ cursor: string | undefined,
+ limit: number,
+ ): Promise {
+ 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
+ }
+}
diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts
new file mode 100644
index 0000000000..0063443343
--- /dev/null
+++ b/src/lib/api/feed/types.ts
@@ -0,0 +1,17 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+
+export interface FeedAPIResponse {
+ cursor?: string
+ feed: AppBskyFeedDefs.FeedViewPost[]
+}
+
+export interface FeedAPI {
+ reset(): void
+ peekLatest(): Promise
+ fetchNext({limit}: {limit: number}): Promise
+}
+
+export interface FeedSourceInfo {
+ uri: string
+ displayName: string
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 233f8a473a..fef7be2f39 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
-import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
+import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
export function GridIcon({
style,
@@ -884,45 +884,7 @@ export function HandIcon({
)
}
-export function SatelliteDishIconSolid({
- style,
- size,
- strokeWidth = 1.5,
-}: {
- style?: StyleProp
- size?: string | number
- strokeWidth?: number
-}) {
- return (
-
- )
-}
-
-export function SatelliteDishIcon({
+export function HashtagIcon({
style,
size,
strokeWidth = 1.5,
@@ -934,26 +896,16 @@ export function SatelliteDishIcon({
return (
)
}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 7159bcb51d..cc7a468e92 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,7 +9,6 @@ export type CommonNavigatorParams = {
ModerationMuteLists: undefined
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
- DiscoverFeeds: undefined
Settings: undefined
Profile: {name: string; hideBackButton?: boolean}
ProfileFollowers: {name: string}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index b509aad01c..671dc97815 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -129,6 +129,15 @@ export function listUriToHref(url: string): string {
}
}
+export function feedUriToHref(url: string): string {
+ try {
+ const {hostname, rkey} = new AtUri(url)
+ return `/profile/${hostname}/feed/${rkey}`
+ } catch {
+ return ''
+ }
+}
+
export function getYoutubeVideoId(link: string): string | undefined {
let url
try {
diff --git a/src/routes.ts b/src/routes.ts
index 45a8fa5724..7c356eb1b3 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -4,7 +4,6 @@ export const router = new Router({
Home: '/',
Search: '/search',
Feeds: '/feeds',
- DiscoverFeeds: '/search/feeds',
Notifications: '/notifications',
Settings: '/settings',
Moderation: '/moderation',
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
deleted file mode 100644
index 95574fb560..0000000000
--- a/src/state/models/feeds/multi-feed.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {AtUri} from '@atproto/api'
-import {bundleAsync} from 'lib/async/bundle'
-import {RootStoreModel} from '../root-store'
-import {CustomFeedModel} from './custom-feed'
-import {PostsFeedModel} from './posts'
-import {PostsFeedSliceModel} from './posts-slice'
-import {makeProfileLink} from 'lib/routes/links'
-
-const FEED_PAGE_SIZE = 10
-const FEEDS_PAGE_SIZE = 3
-
-export type MultiFeedItem =
- | {
- _reactKey: string
- type: 'header'
- }
- | {
- _reactKey: string
- type: 'feed-header'
- avatar: string | undefined
- title: string
- }
- | {
- _reactKey: string
- type: 'feed-slice'
- slice: PostsFeedSliceModel
- }
- | {
- _reactKey: string
- type: 'feed-loading'
- }
- | {
- _reactKey: string
- type: 'feed-error'
- error: string
- }
- | {
- _reactKey: string
- type: 'feed-footer'
- title: string
- uri: string
- }
- | {
- _reactKey: string
- type: 'footer'
- }
-
-export class PostsMultiFeedModel {
- // state
- isLoading = false
- isRefreshing = false
- hasLoaded = false
- hasMore = true
-
- // data
- feedInfos: CustomFeedModel[] = []
- feeds: PostsFeedModel[] = []
-
- constructor(public rootStore: RootStoreModel) {
- makeAutoObservable(this, {rootStore: false}, {autoBind: true})
- }
-
- get hasContent() {
- return this.feeds.length !== 0
- }
-
- get isEmpty() {
- return this.hasLoaded && !this.hasContent
- }
-
- get items() {
- const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
- for (let i = 0; i < this.feedInfos.length; i++) {
- if (!this.feeds[i]) {
- break
- }
- const feed = this.feeds[i]
- const feedInfo = this.feedInfos[i]
- const urip = new AtUri(feedInfo.uri)
- items.push({
- _reactKey: `__feed_header_${i}__`,
- type: 'feed-header',
- avatar: feedInfo.data.avatar,
- title: feedInfo.displayName,
- })
- if (feed.isLoading) {
- items.push({
- _reactKey: `__feed_loading_${i}__`,
- type: 'feed-loading',
- })
- } else if (feed.hasError) {
- items.push({
- _reactKey: `__feed_error_${i}__`,
- type: 'feed-error',
- error: feed.error,
- })
- } else {
- for (let j = 0; j < feed.slices.length; j++) {
- items.push({
- _reactKey: `__feed_slice_${i}_${j}__`,
- type: 'feed-slice',
- slice: feed.slices[j],
- })
- }
- }
- items.push({
- _reactKey: `__feed_footer_${i}__`,
- type: 'feed-footer',
- title: feedInfo.displayName,
- uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey),
- })
- }
- if (!this.hasMore && this.hasContent) {
- // only show if hasContent to avoid double discover-feed links
- items.push({_reactKey: '__footer__', type: 'footer'})
- }
- return items
- }
-
- // public api
- // =
-
- /**
- * Nuke all data
- */
- clear() {
- this.rootStore.log.debug('MultiFeedModel:clear')
- this.isLoading = false
- this.isRefreshing = false
- this.hasLoaded = false
- this.hasMore = true
- this.feeds = []
- }
-
- /**
- * Register any event listeners. Returns a cleanup function.
- */
- registerListeners() {
- const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
- return () => sub.remove()
- }
-
- /**
- * Reset and load
- */
- async refresh() {
- this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
- await this.loadMore(true)
- }
-
- /**
- * Load latest in the active feeds
- */
- loadLatest() {
- for (const feed of this.feeds) {
- /* dont await */ feed.refresh()
- }
- }
-
- /**
- * Load more posts to the end of the feed
- */
- loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
- if (!isRefreshing && !this.hasMore) {
- return
- }
- if (isRefreshing) {
- this.isRefreshing = true // set optimistically for UI
- this.feeds = []
- }
- this._xLoading(isRefreshing)
- const start = this.feeds.length
- const newFeeds: PostsFeedModel[] = []
- for (
- let i = start;
- i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
- i++
- ) {
- const feed = new PostsFeedModel(this.rootStore, 'custom', {
- feed: this.feedInfos[i].uri,
- })
- feed.pageSize = FEED_PAGE_SIZE
- await feed.setup()
- newFeeds.push(feed)
- }
- runInAction(() => {
- this.feeds = this.feeds.concat(newFeeds)
- this.hasMore = this.feeds.length < this.feedInfos.length
- })
- this._xIdle()
- })
-
- /**
- * Attempt to load more again after a failure
- */
- async retryLoadMore() {
- this.hasMore = true
- return this.loadMore()
- }
-
- /**
- * Removes posts from the feed upon deletion.
- */
- onPostDeleted(uri: string) {
- for (const f of this.feeds) {
- f.onPostDeleted(uri)
- }
- }
-
- // state transitions
- // =
-
- _xLoading(isRefreshing = false) {
- this.isLoading = true
- this.isRefreshing = isRefreshing
- }
-
- _xIdle() {
- this.isLoading = false
- this.isRefreshing = false
- this.hasLoaded = true
- }
-
- // helper functions
- // =
-}
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
index 16e4eef150..2501cef6fc 100644
--- a/src/state/models/feeds/posts-slice.ts
+++ b/src/state/models/feeds/posts-slice.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {PostsFeedItemModel} from './post'
+import {FeedSourceInfo} from 'lib/api/feed/types'
export class PostsFeedSliceModel {
// ui state
@@ -9,9 +10,11 @@ export class PostsFeedSliceModel {
// data
items: PostsFeedItemModel[] = []
+ source: FeedSourceInfo | undefined
constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
this._reactKey = slice._reactKey
+ this.source = slice.source
for (let i = 0; i < slice.items.length; i++) {
this.items.push(
new PostsFeedItemModel(
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index c88249c8f3..d4e62533ee 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -14,6 +14,13 @@ import {PostsFeedSliceModel} from './posts-slice'
import {track} from 'lib/analytics/analytics'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
+import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
+import {FollowingFeedAPI} from 'lib/api/feed/following'
+import {AuthorFeedAPI} from 'lib/api/feed/author'
+import {LikesFeedAPI} from 'lib/api/feed/likes'
+import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {MergeFeedAPI} from 'lib/api/feed/merge'
+
const PAGE_SIZE = 30
type Options = {
@@ -27,6 +34,7 @@ type Options = {
type QueryParams =
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
+ | GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
export class PostsFeedModel {
@@ -41,8 +49,8 @@ export class PostsFeedModel {
loadMoreError = ''
params: QueryParams
hasMore = true
- loadMoreCursor: string | undefined
pollCursor: string | undefined
+ api: FeedAPI
tuner = new FeedTuner()
pageSize = PAGE_SIZE
options: Options = {}
@@ -50,7 +58,7 @@ export class PostsFeedModel {
// used to linearize async modifications to state
lock = new AwaitLock()
- // used to track if what's hot is coming up empty
+ // used to track if a feed is coming up empty
emptyFetches = 0
// data
@@ -58,7 +66,7 @@ export class PostsFeedModel {
constructor(
public rootStore: RootStoreModel,
- public feedType: 'home' | 'author' | 'custom' | 'likes',
+ public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
params: QueryParams,
options?: Options,
) {
@@ -67,12 +75,33 @@ export class PostsFeedModel {
{
rootStore: false,
params: false,
- loadMoreCursor: false,
},
{autoBind: true},
)
this.params = params
this.options = options || {}
+ if (feedType === 'home') {
+ this.api = new MergeFeedAPI(rootStore)
+ } else if (feedType === 'following') {
+ this.api = new FollowingFeedAPI(rootStore)
+ } else if (feedType === 'author') {
+ this.api = new AuthorFeedAPI(
+ rootStore,
+ params as GetAuthorFeed.QueryParams,
+ )
+ } else if (feedType === 'likes') {
+ this.api = new LikesFeedAPI(
+ rootStore,
+ params as GetActorLikes.QueryParams,
+ )
+ } else if (feedType === 'custom') {
+ this.api = new CustomFeedAPI(
+ rootStore,
+ params as GetCustomFeed.QueryParams,
+ )
+ } else {
+ this.api = new FollowingFeedAPI(rootStore)
+ }
}
get hasContent() {
@@ -105,7 +134,6 @@ export class PostsFeedModel {
this.hasLoaded = false
this.error = ''
this.hasMore = true
- this.loadMoreCursor = undefined
this.pollCursor = undefined
this.slices = []
this.tuner.reset()
@@ -113,6 +141,8 @@ export class PostsFeedModel {
get feedTuners() {
const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
+ const areRepliesByFollowedOnlyEnabled =
+ this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled
const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
const areQuotePostsEnabled =
@@ -126,7 +156,7 @@ export class PostsFeedModel {
),
]
}
- if (this.feedType === 'home') {
+ if (this.feedType === 'home' || this.feedType === 'following') {
const feedTuners = []
if (areRepostsEnabled) {
@@ -136,7 +166,13 @@ export class PostsFeedModel {
}
if (areRepliesEnabled) {
- feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold}))
+ feedTuners.push(
+ FeedTuner.thresholdRepliesOnly({
+ userDid: this.rootStore.session.data?.did || '',
+ minLikes: repliesThreshold,
+ followedOnly: areRepliesByFollowedOnlyEnabled,
+ }),
+ )
} else {
feedTuners.push(FeedTuner.removeReplies)
}
@@ -161,10 +197,11 @@ export class PostsFeedModel {
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
+ this.api.reset()
this.tuner.reset()
this._xLoading(isRefreshing)
try {
- const res = await this._getFeed({limit: this.pageSize})
+ const res = await this.api.fetchNext({limit: this.pageSize})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@@ -201,8 +238,7 @@ export class PostsFeedModel {
}
this._xLoading()
try {
- const res = await this._getFeed({
- cursor: this.loadMoreCursor,
+ const res = await this.api.fetchNext({
limit: this.pageSize,
})
await this._appendAll(res)
@@ -230,44 +266,6 @@ export class PostsFeedModel {
return this.loadMore()
}
- /**
- * Update content in-place
- */
- update = bundleAsync(async () => {
- await this.lock.acquireAsync()
- try {
- if (!this.slices.length) {
- return
- }
- this._xLoading()
- let numToFetch = this.slices.length
- let cursor
- try {
- do {
- const res: GetTimeline.Response = await this._getFeed({
- cursor,
- limit: Math.min(numToFetch, 100),
- })
- if (res.data.feed.length === 0) {
- break // sanity check
- }
- this._updateAll(res)
- numToFetch -= res.data.feed.length
- cursor = res.data.cursor
- } while (cursor && numToFetch > 0)
- this._xIdle()
- } catch (e: any) {
- this._xIdle() // don't bubble the error to the user
- this.rootStore.log.error('FeedView: Failed to update', {
- params: this.params,
- e,
- })
- }
- } finally {
- this.lock.release()
- }
- })
-
/**
* Check if new posts are available
*/
@@ -275,9 +273,9 @@ export class PostsFeedModel {
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
return
}
- const res = await this._getFeed({limit: 1})
- if (res.data.feed[0]) {
- const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+ const post = await this.api.peekLatest()
+ if (post) {
+ const slices = this.tuner.tune([post], this.feedTuners, {
dryRun: true,
})
if (slices[0]) {
@@ -345,33 +343,27 @@ export class PostsFeedModel {
// helper functions
// =
- async _replaceAll(
- res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
- ) {
- this.pollCursor = res.data.feed[0]?.post.uri
+ async _replaceAll(res: FeedAPIResponse) {
+ this.pollCursor = res.feed[0]?.post.uri
return this._appendAll(res, true)
}
- async _appendAll(
- res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
- replace = false,
- ) {
- this.loadMoreCursor = res.data.cursor
- this.hasMore = !!this.loadMoreCursor
+ async _appendAll(res: FeedAPIResponse, replace = false) {
+ this.hasMore = !!res.cursor
if (replace) {
this.emptyFetches = 0
}
this.rootStore.me.follows.hydrateProfiles(
- res.data.feed.map(item => item.post.author),
+ res.feed.map(item => item.post.author),
)
- for (const item of res.data.feed) {
+ for (const item of res.feed) {
this.rootStore.posts.fromFeedItem(item)
}
const slices = this.options.isSimpleFeed
- ? res.data.feed.map(item => new FeedViewPostsSlice([item]))
- : this.tuner.tune(res.data.feed, this.feedTuners)
+ ? res.feed.map(item => new FeedViewPostsSlice([item]))
+ : this.tuner.tune(res.feed, this.feedTuners)
const toAppend: PostsFeedSliceModel[] = []
for (const slice of slices) {
@@ -401,54 +393,4 @@ export class PostsFeedModel {
}
})
}
-
- _updateAll(
- res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
- ) {
- for (const item of res.data.feed) {
- this.rootStore.posts.fromFeedItem(item)
- const existingSlice = this.slices.find(slice =>
- slice.containsUri(item.post.uri),
- )
- if (existingSlice) {
- const existingItem = existingSlice.items.find(
- item2 => item2.post.uri === item.post.uri,
- )
- if (existingItem) {
- existingItem.copyMetrics(item)
- }
- }
- }
- }
-
- protected async _getFeed(
- params: QueryParams,
- ): Promise<
- GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
- > {
- params = Object.assign({}, this.params, params)
- if (this.feedType === 'home') {
- return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
- } else if (this.feedType === 'custom') {
- const res = await this.rootStore.agent.app.bsky.feed.getFeed(
- params as GetCustomFeed.QueryParams,
- )
- // NOTE
- // some custom feeds fail to enforce the pagination limit
- // so we manually truncate here
- // -prf
- if (params.limit && res.data.feed.length > params.limit) {
- res.data.feed = res.data.feed.slice(0, params.limit)
- }
- return res
- } else if (this.feedType === 'author') {
- return this.rootStore.agent.getAuthorFeed(
- params as GetAuthorFeed.QueryParams,
- )
- } else {
- return this.rootStore.agent.getActorLikes(
- params as GetActorLikes.QueryParams,
- )
- }
- }
}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 6204e0d102..1a81072a25 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -139,7 +139,7 @@ export class RootStoreModel {
this.agent = agent
applyDebugHeader(this.agent)
this.me.clear()
- /* dont await */ this.preferences.sync()
+ await this.preferences.sync()
await this.me.load()
if (!hadSession) {
await resetNavigation()
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
new file mode 100644
index 0000000000..f9ad06f775
--- /dev/null
+++ b/src/state/models/ui/my-feeds.ts
@@ -0,0 +1,157 @@
+import {makeAutoObservable} from 'mobx'
+import {FeedsDiscoveryModel} from '../discovery/feeds'
+import {CustomFeedModel} from '../feeds/custom-feed'
+import {RootStoreModel} from '../root-store'
+
+export type MyFeedsItem =
+ | {
+ _reactKey: string
+ type: 'spinner'
+ }
+ | {
+ _reactKey: string
+ type: 'discover-feeds-loading'
+ }
+ | {
+ _reactKey: string
+ type: 'error'
+ error: string
+ }
+ | {
+ _reactKey: string
+ type: 'saved-feeds-header'
+ }
+ | {
+ _reactKey: string
+ type: 'saved-feed'
+ feed: CustomFeedModel
+ }
+ | {
+ _reactKey: string
+ type: 'saved-feeds-load-more'
+ }
+ | {
+ _reactKey: string
+ type: 'discover-feeds-header'
+ }
+ | {
+ _reactKey: string
+ type: 'discover-feeds-no-results'
+ }
+ | {
+ _reactKey: string
+ type: 'discover-feed'
+ feed: CustomFeedModel
+ }
+
+export class MyFeedsUIModel {
+ discovery: FeedsDiscoveryModel
+
+ constructor(public rootStore: RootStoreModel) {
+ makeAutoObservable(this)
+ this.discovery = new FeedsDiscoveryModel(this.rootStore)
+ }
+
+ get saved() {
+ return this.rootStore.me.savedFeeds
+ }
+
+ get isRefreshing() {
+ return !this.saved.isLoading && this.saved.isRefreshing
+ }
+
+ get isLoading() {
+ return this.saved.isLoading || this.discovery.isLoading
+ }
+
+ async setup() {
+ if (!this.saved.hasLoaded) {
+ await this.saved.refresh()
+ }
+ if (!this.discovery.hasLoaded) {
+ await this.discovery.refresh()
+ }
+ }
+
+ async refresh() {
+ return Promise.all([this.saved.refresh(), this.discovery.refresh()])
+ }
+
+ async loadMore() {
+ return this.discovery.loadMore()
+ }
+
+ get items() {
+ let items: MyFeedsItem[] = []
+
+ items.push({
+ _reactKey: '__saved_feeds_header__',
+ type: 'saved-feeds-header',
+ })
+ if (this.saved.isLoading) {
+ items.push({
+ _reactKey: '__saved_feeds_loading__',
+ type: 'spinner',
+ })
+ } else if (this.saved.hasError) {
+ items.push({
+ _reactKey: '__saved_feeds_error__',
+ type: 'error',
+ error: this.saved.error,
+ })
+ } else {
+ const savedSorted = this.saved.all
+ .slice()
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
+ items = items.concat(
+ savedSorted.map(feed => ({
+ _reactKey: `saved-${feed.uri}`,
+ type: 'saved-feed',
+ feed,
+ })),
+ )
+ items.push({
+ _reactKey: '__saved_feeds_load_more__',
+ type: 'saved-feeds-load-more',
+ })
+ }
+
+ items.push({
+ _reactKey: '__discover_feeds_header__',
+ type: 'discover-feeds-header',
+ })
+ if (this.discovery.isLoading && !this.discovery.hasContent) {
+ items.push({
+ _reactKey: '__discover_feeds_loading__',
+ type: 'discover-feeds-loading',
+ })
+ } else if (this.discovery.hasError) {
+ items.push({
+ _reactKey: '__discover_feeds_error__',
+ type: 'error',
+ error: this.discovery.error,
+ })
+ } else if (this.discovery.isEmpty) {
+ items.push({
+ _reactKey: '__discover_feeds_no_results__',
+ type: 'discover-feeds-no-results',
+ })
+ } else {
+ items = items.concat(
+ this.discovery.feeds.map(feed => ({
+ _reactKey: `discover-${feed.uri}`,
+ type: 'discover-feed',
+ feed,
+ })),
+ )
+ if (this.discovery.isLoading) {
+ items.push({
+ _reactKey: '__discover_feeds_loading_more__',
+ type: 'spinner',
+ })
+ }
+ }
+
+ return items
+ }
+}
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 64ab4ecba6..7232a7b74a 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -50,9 +50,11 @@ export class PreferencesModel {
pinnedFeeds: string[] = []
birthDate: Date | undefined = undefined
homeFeedRepliesEnabled: boolean = true
- homeFeedRepliesThreshold: number = 2
+ homeFeedRepliesByFollowedOnlyEnabled: boolean = true
+ homeFeedRepliesThreshold: number = 0
homeFeedRepostsEnabled: boolean = true
homeFeedQuotePostsEnabled: boolean = true
+ homeFeedMergeFeedEnabled: boolean = false
requireAltTextEnabled: boolean = false
// used to linearize async modifications to state
@@ -78,9 +80,12 @@ export class PreferencesModel {
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
homeFeedRepliesEnabled: this.homeFeedRepliesEnabled,
+ homeFeedRepliesByFollowedOnlyEnabled:
+ this.homeFeedRepliesByFollowedOnlyEnabled,
homeFeedRepliesThreshold: this.homeFeedRepliesThreshold,
homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
+ homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
requireAltTextEnabled: this.requireAltTextEnabled,
}
}
@@ -148,6 +153,14 @@ export class PreferencesModel {
) {
this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled
}
+ // check if home feed replies "followed only" are enabled in preferences, then hydrate
+ if (
+ hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') &&
+ typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean'
+ ) {
+ this.homeFeedRepliesByFollowedOnlyEnabled =
+ v.homeFeedRepliesByFollowedOnlyEnabled
+ }
// check if home feed replies threshold is enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepliesThreshold') &&
@@ -169,6 +182,13 @@ export class PreferencesModel {
) {
this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled
}
+ // check if home feed mergefeed is enabled in preferences, then hydrate
+ if (
+ hasProp(v, 'homeFeedMergeFeedEnabled') &&
+ typeof v.homeFeedMergeFeedEnabled === 'boolean'
+ ) {
+ this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
+ }
// check if requiring alt text is enabled in preferences, then hydrate
if (
hasProp(v, 'requireAltTextEnabled') &&
@@ -449,6 +469,11 @@ export class PreferencesModel {
this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled
}
+ toggleHomeFeedRepliesByFollowedOnlyEnabled() {
+ this.homeFeedRepliesByFollowedOnlyEnabled =
+ !this.homeFeedRepliesByFollowedOnlyEnabled
+ }
+
setHomeFeedRepliesThreshold(threshold: number) {
this.homeFeedRepliesThreshold = threshold
}
@@ -461,6 +486,10 @@ export class PreferencesModel {
this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled
}
+ toggleHomeFeedMergeFeedEnabled() {
+ this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
+ }
+
toggleRequireAltTextEnabled() {
this.requireAltTextEnabled = !this.requireAltTextEnabled
}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 11951b0eeb..8525426bf2 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -240,13 +240,6 @@ export class ProfileUiModel {
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
}
- async update() {
- const view = this.currentView
- if (view instanceof PostsFeedModel) {
- await view.update()
- }
- }
-
async refresh() {
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
}
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index d457d71362..4ca22282da 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -21,11 +21,13 @@ export const Feed = observer(function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
+ ListHeaderComponent,
}: {
view: NotificationsFeedModel
scrollElRef?: MutableRefObject | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
+ ListHeaderComponent?: () => JSX.Element
}) {
const pal = usePalette('default')
const [isPTRing, setIsPTRing] = React.useState(false)
@@ -142,6 +144,7 @@ export const Feed = observer(function Feed({
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
+ ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={FeedFooter}
refreshControl={
) : null}
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 0083e953b9..02aa623cc2 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -12,15 +12,17 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
- const {isMobile} = useWebMediaQueries()
+ const {isMobile, isTablet} = useWebMediaQueries()
if (isMobile) {
return
+ } else if (isTablet) {
+ return
} else {
- return
+ return null
}
})
-const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
+const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const store = useStores()
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 5ce2906b32..30a7125417 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -9,8 +9,8 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
-import {CogIcon} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {HITSLOP_10} from 'lib/constants'
@@ -67,12 +67,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
-
+ accessibilityLabel="Home Feed Preferences"
+ accessibilityHint="">
+
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 7a5a457714..1cc177d17b 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -357,6 +357,8 @@ export const PostThread = observer(function PostThread({
}
onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion}
+ // @ts-ignore our .web version only -prf
+ desktopFixedHeight
/>
)
})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5b5fee0ca4..37c7ece471 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -483,15 +483,6 @@ export const PostThreadItem = observer(function PostThreadItem({
/>
)}
- {needsTranslation && (
-
-
-
- Translate this post
-
-
-
- )}
- store.preferences.contentLanguages.length > 0 &&
- !isPostInLanguage(item.post, store.preferences.contentLanguages),
- [item.post, store.preferences.contentLanguages],
- )
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
@@ -256,15 +250,6 @@ const PostLoaded = observer(function PostLoadedImpl({
/>
) : null}
- {needsTranslation && (
-
-
-
- Translate this post
-
-
-
- )}
- store.preferences.contentLanguages.length > 0 &&
- !isPostInLanguage(item.post, store.preferences.contentLanguages),
- [item.post, store.preferences.contentLanguages],
- )
const onPressReply = React.useCallback(() => {
track('FeedItem:PostReply')
@@ -179,7 +176,27 @@ export const FeedItem = observer(function FeedItemImpl({
- {item.reasonRepost && (
+ {source ? (
+
+
+ From{' '}
+
+
+
+ ) : item.reasonRepost ? (
- )}
+ ) : null}
@@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({
/>
) : null}
- {needsTranslation && (
-
-
-
- Translate this post
-
-
-
- )}
@@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
{
- navigation.navigate('DiscoverFeeds')
+ navigation.navigate('Feeds')
}, [navigation])
return (
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
deleted file mode 100644
index 9c8f4f246b..0000000000
--- a/src/view/com/posts/MultiFeed.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-import React, {MutableRefObject} from 'react'
-import {observer} from 'mobx-react-lite'
-import {
- ActivityIndicator,
- RefreshControl,
- StyleProp,
- StyleSheet,
- View,
- ViewStyle,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FlatList} from '../util/Views'
-import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed'
-import {FeedSlice} from './FeedSlice'
-import {Text} from '../util/text/Text'
-import {Link} from '../util/Link'
-import {UserAvatar} from '../util/UserAvatar'
-import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
-import {s} from 'lib/styles'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {CogIcon} from 'lib/icons'
-
-export const MultiFeed = observer(function Feed({
- multifeed,
- style,
- scrollElRef,
- onScroll,
- scrollEventThrottle,
- testID,
- headerOffset = 0,
- extraData,
-}: {
- multifeed: PostsMultiFeedModel
- style?: StyleProp
- scrollElRef?: MutableRefObject | null>
- onPressTryAgain?: () => void
- onScroll?: OnScrollCb
- scrollEventThrottle?: number
- renderEmptyState?: () => JSX.Element
- testID?: string
- headerOffset?: number
- extraData?: any
-}) {
- const pal = usePalette('default')
- const theme = useTheme()
- const {isMobile} = useWebMediaQueries()
- const {track} = useAnalytics()
- const [isRefreshing, setIsRefreshing] = React.useState(false)
-
- // events
- // =
-
- const onRefresh = React.useCallback(async () => {
- track('MultiFeed:onRefresh')
- setIsRefreshing(true)
- try {
- await multifeed.refresh()
- } catch (err) {
- multifeed.rootStore.log.error('Failed to refresh posts feed', err)
- }
- setIsRefreshing(false)
- }, [multifeed, track, setIsRefreshing])
-
- const onEndReached = React.useCallback(async () => {
- track('MultiFeed:onEndReached')
- try {
- await multifeed.loadMore()
- } catch (err) {
- multifeed.rootStore.log.error('Failed to load more posts', err)
- }
- }, [multifeed, track])
-
- // rendering
- // =
-
- const renderItem = React.useCallback(
- ({item}: {item: MultiFeedItem}) => {
- if (item.type === 'header') {
- if (!isMobile) {
- return (
- <>
-
-
- My Feeds
-
-
-
-
-
-
- >
- )
- }
- return (
- <>
-
-
- >
- )
- } else if (item.type === 'feed-header') {
- return (
-
-
-
- {item.title}
-
-
- )
- } else if (item.type === 'feed-slice') {
- return
- } else if (item.type === 'feed-loading') {
- return
- } else if (item.type === 'feed-error') {
- return
- } else if (item.type === 'feed-footer') {
- return (
-
-
- See more from {item.title}
-
-
-
- )
- } else if (item.type === 'footer') {
- return
- }
- return null
- },
- [pal, isMobile],
- )
-
- const ListFooter = React.useCallback(
- () =>
- multifeed.isLoading && !isRefreshing ? (
-
-
-
- ) : (
-
- ),
- [multifeed.isLoading, isRefreshing, pal],
- )
-
- return (
-
- {multifeed.items.length > 0 && (
- item._reactKey}
- renderItem={renderItem}
- ListFooterComponent={ListFooter}
- refreshControl={
-
- }
- contentContainerStyle={s.contentContainer}
- style={[{paddingTop: headerOffset}, pal.view, styles.container]}
- onScroll={onScroll}
- scrollEventThrottle={scrollEventThrottle}
- indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
- onEndReached={onEndReached}
- onEndReachedThreshold={0.6}
- removeClippedSubviews={true}
- contentOffset={{x: 0, y: headerOffset * -1}}
- extraData={extraData}
- // @ts-ignore our .web version only -prf
- desktopFixedHeight
- />
- )}
-
- )
-})
-
-function DiscoverLink() {
- const pal = usePalette('default')
- return (
-
-
-
- Discover new feeds
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- height: '100%',
- },
- header: {
- borderTopWidth: 1,
- marginBottom: 4,
- },
- headerDesktop: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- borderBottomWidth: 1,
- marginBottom: 4,
- paddingHorizontal: 16,
- paddingVertical: 8,
- },
- feedHeader: {
- flexDirection: 'row',
- gap: 8,
- alignItems: 'center',
- paddingHorizontal: 16,
- paddingBottom: 8,
- marginTop: 12,
- },
- feedHeaderTitle: {
- fontWeight: 'bold',
- },
- feedFooter: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingHorizontal: 16,
- paddingVertical: 16,
- marginBottom: 12,
- borderTopWidth: 1,
- borderBottomWidth: 1,
- },
- discoverLink: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: 8,
- paddingHorizontal: 14,
- paddingVertical: 12,
- marginHorizontal: 8,
- marginVertical: 8,
- gap: 8,
- },
- loadMore: {
- paddingTop: 10,
- },
-})
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 321b6ab63a..d4df2bec44 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -26,6 +26,7 @@ import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
import {isAndroid, isDesktopWeb} from 'platform/detection'
import {sanitizeUrl} from '@braintree/sanitize-url'
+import {PressableWithHover} from './PressableWithHover'
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
type Event =
@@ -38,6 +39,7 @@ interface Props extends ComponentProps {
href?: string
title?: string
children?: React.ReactNode
+ hoverStyle?: StyleProp
noFeedback?: boolean
asAnchor?: boolean
anchorNoUnderline?: boolean
@@ -112,8 +114,9 @@ export const Link = observer(function Link({
props.accessibilityLabel = title
}
+ const Com = props.hoverStyle ? PressableWithHover : Pressable
return (
-
{children ? children : {title || 'link'}}
-
+
)
})
@@ -137,6 +140,7 @@ export const TextLink = observer(function TextLink({
lineHeight,
dataSet,
title,
+ onPress,
}: {
testID?: string
type?: TypographyVariant
@@ -154,9 +158,14 @@ export const TextLink = observer(function TextLink({
props.onPress = React.useCallback(
(e?: Event) => {
+ if (onPress) {
+ e?.preventDefault?.()
+ // @ts-ignore function signature differs by platform -prf
+ return onPress()
+ }
return onPressInner(store, navigation, sanitizeUrl(href), e)
},
- [store, navigation, href],
+ [onPress, store, navigation, href],
)
const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href)
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index bf39fd50c6..d7ab1be541 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -174,6 +174,60 @@ export function ProfileCardFeedLoadingPlaceholder() {
)
}
+export function FeedLoadingPlaceholder({
+ style,
+}: {
+ style?: StyleProp
+}) {
+ const pal = usePalette('default')
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function FeedFeedLoadingPlaceholder() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
const styles = StyleSheet.create({
loadingPlaceholder: {
borderRadius: 6,
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
new file mode 100644
index 0000000000..4eff38a315
--- /dev/null
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+ StyleProp,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {CenteredView} from './Views'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {NavigationProp} from 'lib/routes/types'
+
+const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
+
+export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
+ showBackButton = true,
+ style,
+ children,
+}: React.PropsWithChildren<{
+ showBackButton?: boolean
+ style?: StyleProp
+}>) {
+ const pal = usePalette('default')
+ const store = useStores()
+ const navigation = useNavigation()
+ const {track} = useAnalytics()
+ const {isMobile} = useWebMediaQueries()
+ const canGoBack = navigation.canGoBack()
+
+ const onPressBack = React.useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('Home')
+ }
+ }, [navigation])
+
+ const onPressMenu = React.useCallback(() => {
+ track('ViewHeader:MenuButtonClicked')
+ store.shell.openDrawer()
+ }, [track, store])
+
+ const Container = isMobile ? View : CenteredView
+ return (
+
+ {showBackButton ? (
+
+ {canGoBack ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+ {children}
+
+ )
+})
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 18,
+ paddingVertical: 12,
+ width: '100%',
+ },
+ headerMobile: {
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ backBtn: {
+ width: 30,
+ height: 30,
+ },
+ backBtnWide: {
+ width: 30,
+ height: 30,
+ paddingHorizontal: 6,
+ },
+ backIcon: {
+ marginTop: 6,
+ },
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 0f34f75aa6..7a42ab4d32 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -118,7 +118,7 @@ export function UserAvatar({
return {
width: size,
height: size,
- borderRadius: 8,
+ borderRadius: size > 32 ? 8 : 3,
}
}
return {
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
new file mode 100644
index 0000000000..c1eb82bd49
--- /dev/null
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {
+ StyleProp,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from 'react-native'
+import {
+ FontAwesomeIcon,
+ FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {MagnifyingGlassIcon} from 'lib/icons'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+
+interface Props {
+ query: string
+ setIsInputFocused?: (v: boolean) => void
+ onChangeQuery: (v: string) => void
+ onPressCancelSearch: () => void
+ onSubmitQuery: () => void
+ style?: StyleProp
+}
+export function SearchInput({
+ query,
+ setIsInputFocused,
+ onChangeQuery,
+ onPressCancelSearch,
+ onSubmitQuery,
+ style,
+}: Props) {
+ const theme = useTheme()
+ const pal = usePalette('default')
+ const textInput = React.useRef(null)
+
+ const onPressCancelSearchInner = React.useCallback(() => {
+ onPressCancelSearch()
+ textInput.current?.blur()
+ }, [onPressCancelSearch, textInput])
+
+ return (
+
+
+ setIsInputFocused?.(true)}
+ onBlur={() => setIsInputFocused?.(false)}
+ onChangeText={onChangeQuery}
+ onSubmitEditing={onSubmitQuery}
+ accessibilityRole="search"
+ accessibilityLabel="Search"
+ accessibilityHint=""
+ autoCorrect={false}
+ autoCapitalize="none"
+ />
+ {query ? (
+
+
+
+ ) : undefined}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: 30,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ },
+ icon: {
+ marginRight: 6,
+ alignSelf: 'center',
+ },
+ input: {
+ flex: 1,
+ fontSize: 17,
+ minWidth: 0, // overflow mitigation for firefox
+ },
+ cancelBtn: {
+ paddingLeft: 10,
+ },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index ae9cb9361e..6b73edd4b3 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1 +1,86 @@
-export * from './LoadLatestBtnMobile'
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {clamp} from 'lodash'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {colors} from 'lib/styles'
+import {HITSLOP_20} from 'lib/constants'
+
+export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
+ onPress,
+ label,
+ showIndicator,
+}: {
+ onPress: () => void
+ label: string
+ showIndicator: boolean
+ minimalShellMode?: boolean // NOTE not used on mobile -prf
+}) {
+ const store = useStores()
+ const pal = usePalette('default')
+ const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+ const safeAreaInsets = useSafeAreaInsets()
+ return (
+
+
+ {showIndicator && }
+
+ )
+})
+
+const styles = StyleSheet.create({
+ loadLatest: {
+ position: 'absolute',
+ left: 18,
+ bottom: 35,
+ borderWidth: 1,
+ width: 52,
+ height: 52,
+ borderRadius: 26,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ loadLatestTablet: {
+ // @ts-ignore web only
+ left: '50vw',
+ // @ts-ignore web only -prf
+ transform: 'translateX(-282px)',
+ },
+ loadLatestDesktop: {
+ // @ts-ignore web only
+ left: '50vw',
+ // @ts-ignore web only -prf
+ transform: 'translateX(-382px)',
+ },
+ indicator: {
+ position: 'absolute',
+ top: 3,
+ right: 3,
+ backgroundColor: colors.blue3,
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ borderWidth: 1,
+ },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
deleted file mode 100644
index 83c696f7ed..0000000000
--- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = ({
- onPress,
- label,
- showIndicator,
- minimalShellMode,
-}: {
- onPress: () => void
- label: string
- showIndicator: boolean
- minimalShellMode?: boolean
-}) => {
- const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
- if (isMobile) {
- return (
-
- )
- }
- return (
- <>
- {showIndicator && (
-
-
- {label}
-
-
- )}
-
-
-
-
-
- >
- )
-}
-
-const styles = StyleSheet.create({
- loadLatest: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- position: 'absolute',
- // @ts-ignore web only
- left: '50vw',
- // @ts-ignore web only -prf
- transform: 'translateX(-282px)',
- bottom: 40,
- width: 54,
- height: 54,
- borderRadius: 30,
- borderWidth: 1,
- },
- icon: {
- position: 'relative',
- top: 2,
- },
- loadLatestCentered: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- position: 'absolute',
- // @ts-ignore web only
- left: '50vw',
- // @ts-ignore web only -prf
- transform: 'translateX(-50%)',
- top: 60,
- paddingHorizontal: 24,
- paddingVertical: 14,
- borderRadius: 30,
- borderWidth: 1,
- },
- loadLatestCenteredMinimal: {
- top: 20,
- },
-})
diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
deleted file mode 100644
index 3e8add5e91..0000000000
--- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {clamp} from 'lodash'
-import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {colors} from 'lib/styles'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
- onPress,
- label,
- showIndicator,
-}: {
- onPress: () => void
- label: string
- showIndicator: boolean
- minimalShellMode?: boolean // NOTE not used on mobile -prf
-}) {
- const store = useStores()
- const pal = usePalette('default')
- const safeAreaInsets = useSafeAreaInsets()
- return (
-
-
- {showIndicator && }
-
- )
-})
-
-const styles = StyleSheet.create({
- loadLatest: {
- position: 'absolute',
- left: 18,
- bottom: 35,
- borderWidth: 1,
- width: 52,
- height: 52,
- borderRadius: 26,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- },
- indicator: {
- position: 'absolute',
- top: 3,
- right: 3,
- backgroundColor: colors.blue3,
- width: 12,
- height: 12,
- borderRadius: 6,
- borderWidth: 1,
- },
-})
diff --git a/src/view/index.ts b/src/view/index.ts
index 2e4c08ec7f..2fdc34e7b5 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -13,6 +13,7 @@ import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrow
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
+import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
@@ -24,6 +25,7 @@ import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faB
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
+import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
@@ -41,6 +43,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
+import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@@ -54,15 +57,18 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
+import {faList} from '@fortawesome/free-solid-svg-icons/faList'
import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
+import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
+import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
@@ -77,6 +83,7 @@ import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
+import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
@@ -88,11 +95,6 @@ import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
-import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
-import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
-import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
-import {faList} from '@fortawesome/free-solid-svg-icons/faList'
-import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
export function setup() {
library.add(
@@ -109,6 +111,7 @@ export function setup() {
faArrowUpFromBracket,
faArrowUpRightFromSquare,
faArrowRotateLeft,
+ faArrowTrendUp,
faArrowsRotate,
faAt,
faBan,
@@ -120,6 +123,7 @@ export function setup() {
farCalendar,
faCamera,
faCheck,
+ faChevronRight,
faCircle,
faCircleCheck,
farCircleCheck,
@@ -137,6 +141,7 @@ export function setup() {
faExclamation,
farEyeSlash,
faFaceSmile,
+ faFire,
faFloppyDisk,
faGear,
faGlobe,
@@ -150,15 +155,18 @@ export function setup() {
faInfo,
faLanguage,
faLink,
+ faList,
faListUl,
faLock,
faMagnifyingGlass,
faMessage,
faNoteSticky,
faPaste,
+ faPause,
faPen,
faPenNib,
faPenToSquare,
+ faPlay,
faPlus,
faQuoteLeft,
faReply,
@@ -180,14 +188,10 @@ export function setup() {
faUserPlus,
faUserXmark,
faUsersSlash,
+ faThumbtack,
faTicket,
faTrashCan,
- faThumbtack,
faX,
faXmark,
- faPlay,
- faPause,
- faList,
- faChevronRight,
)
}
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index af4d01843e..eaa21f2924 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -1,7 +1,7 @@
import React, {useMemo, useRef} from 'react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useNavigation} from '@react-navigation/native'
+import {useNavigation, useIsFocused} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {CommonNavigatorParams} from 'lib/routes/types'
@@ -14,11 +14,8 @@ import {PostsFeedModel} from 'state/models/feeds/posts'
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {Feed} from 'view/com/posts/Feed'
-import {pluralize} from 'lib/strings/helpers'
-import {sanitizeHandle} from 'lib/strings/handles'
import {TextLink} from 'view/com/util/Link'
-import {UserAvatar} from 'view/com/util/UserAvatar'
-import {ViewHeader} from 'view/com/util/ViewHeader'
+import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
@@ -34,7 +31,6 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState'
import {useAnalytics} from 'lib/analytics/analytics'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {makeProfileLink} from 'lib/routes/links'
import {resolveName} from 'lib/api'
import {CenteredView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
@@ -125,7 +121,10 @@ export const CustomFeedScreenInner = observer(
}: Props & {feedOwnerDid: string}) {
const store = useStores()
const pal = usePalette('default')
- const {isTabletOrDesktop} = useWebMediaQueries()
+ const palInverted = usePalette('inverted')
+ const navigation = useNavigation()
+ const isScreenFocused = useIsFocused()
+ const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {track} = useAnalytics()
const {rkey, name: handleOrDid} = route.params
const uri = useMemo(
@@ -186,6 +185,10 @@ export const CustomFeedScreenInner = observer(
})
}, [store, currentFeed])
+ const onPressViewAuthor = React.useCallback(() => {
+ navigation.navigate('Profile', {name: handleOrDid})
+ }, [handleOrDid, navigation])
+
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
shareUrl(url)
@@ -210,8 +213,39 @@ export const CustomFeedScreenInner = observer(
store.shell.openComposer({})
}, [store])
+ const onSoftReset = React.useCallback(() => {
+ if (isScreenFocused) {
+ onScrollToTop()
+ algoFeed.refresh()
+ }
+ }, [isScreenFocused, onScrollToTop, algoFeed])
+
+ // fires when page within screen is activated/deactivated
+ React.useEffect(() => {
+ if (!isScreenFocused) {
+ return
+ }
+
+ const softResetSub = store.onScreenSoftReset(onSoftReset)
+ return () => {
+ softResetSub.remove()
+ }
+ }, [store, onSoftReset, isScreenFocused])
+
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
+ {
+ testID: 'feedHeaderDropdownViewAuthorBtn',
+ label: 'View author',
+ onPress: onPressViewAuthor,
+ icon: {
+ ios: {
+ name: 'person',
+ },
+ android: '',
+ web: ['far', 'user'],
+ },
+ },
{
testID: 'feedHeaderDropdownToggleSavedBtn',
label: currentFeed?.isSaved
@@ -260,232 +294,12 @@ export const CustomFeedScreenInner = observer(
},
]
return items
- }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
-
- const renderHeaderBtns = React.useCallback(() => {
- return (
-
-
- {currentFeed?.isSaved ? (
-
- ) : undefined}
- {!currentFeed?.isSaved ? (
-
- ) : null}
-
-
-
-
-
-
- )
}, [
- pal,
currentFeed?.isSaved,
- currentFeed?.isLiked,
- isPinned,
- onToggleSaved,
- onTogglePinned,
- onToggleLiked,
- dropdownItems,
- ])
-
- const renderListHeaderComponent = React.useCallback(() => {
- return (
- <>
-
-
-
- {currentFeed?.displayName}
-
- {currentFeed && (
-
- by{' '}
- {currentFeed.data.creator.did === store.me.did ? (
- 'you'
- ) : (
-
- )}
-
- )}
- {isTabletOrDesktop && (
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
- {currentFeed?.data.description ? (
-
- {currentFeed.data.description}
-
- ) : null}
-
- {currentFeed ? (
-
- ) : null}
-
-
-
-
-
- Feed
-
-
-
- >
- )
- }, [
- pal,
- currentFeed,
- store.me.did,
onToggleSaved,
- onToggleLiked,
- onPressShare,
- handleOrDid,
onPressReport,
- rkey,
- isPinned,
- onTogglePinned,
- isTabletOrDesktop,
+ onPressShare,
+ onPressViewAuthor,
])
const renderEmptyState = React.useCallback(() => {
@@ -498,22 +312,100 @@ export const CustomFeedScreenInner = observer(
return (
- {!isTabletOrDesktop && (
-
- )}
+
+
+ {currentFeed ? (
+ store.emitScreenSoftReset()}
+ />
+ ) : (
+ 'Loading...'
+ )}
+
+ {currentFeed ? (
+ <>
+
+ {currentFeed?.isSaved ? (
+
+ ) : (
+
+ )}
+ >
+ ) : null}
+
+
+
+
+
+
{isScrolledDown ? (
@@ -540,36 +432,19 @@ const styles = StyleSheet.create({
paddingBottom: 16,
borderTopWidth: 1,
},
- headerBtns: {
- flexDirection: 'row',
- alignItems: 'center',
+ headerText: {
+ flex: 1,
+ fontWeight: 'bold',
},
- headerBtnsDesktop: {
- marginTop: 8,
- gap: 4,
+ headerBtn: {
+ paddingVertical: 0,
},
headerAddBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
- paddingLeft: 4,
- },
- headerDetails: {
- paddingHorizontal: 16,
- paddingBottom: 16,
- },
- headerDetailsFooter: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- fakeSelector: {
- flexDirection: 'row',
- },
- fakeSelectorItem: {
- paddingHorizontal: 12,
- paddingBottom: 8,
- borderBottomWidth: 3,
+ paddingVertical: 4,
+ paddingLeft: 10,
},
liked: {
color: colors.red3,
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
deleted file mode 100644
index 6aa7a9e314..0000000000
--- a/src/view/screens/DiscoverFeeds.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import React from 'react'
-import {RefreshControl, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {useStores} from 'state/index'
-import {FeedsDiscoveryModel} from 'state/models/discovery/feeds'
-import {CenteredView, FlatList} from 'view/com/util/Views'
-import {CustomFeed} from 'view/com/feeds/CustomFeed'
-import {Text} from 'view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
-import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
-import debounce from 'lodash.debounce'
-
-type Props = NativeStackScreenProps
-export const DiscoverFeedsScreen = withAuthRequired(
- observer(function DiscoverFeedsScreenImpl({}: Props) {
- const store = useStores()
- const pal = usePalette('default')
- const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
- const {isTabletOrDesktop} = useWebMediaQueries()
-
- // search stuff
- const [isInputFocused, setIsInputFocused] = React.useState(false)
- const [query, setQuery] = React.useState('')
- const debouncedSearchFeeds = React.useMemo(
- () => debounce(q => feeds.search(q), 500), // debounce for 500ms
- [feeds],
- )
- const onChangeQuery = React.useCallback(
- (text: string) => {
- setQuery(text)
- if (text.length > 1) {
- debouncedSearchFeeds(text)
- } else {
- feeds.refresh()
- }
- },
- [debouncedSearchFeeds, feeds],
- )
- const onPressClearQuery = React.useCallback(() => {
- setQuery('')
- feeds.refresh()
- }, [feeds])
- const onPressCancelSearch = React.useCallback(() => {
- setIsInputFocused(false)
- setQuery('')
- feeds.refresh()
- }, [feeds])
- const onSubmitQuery = React.useCallback(() => {
- debouncedSearchFeeds(query)
- debouncedSearchFeeds.flush()
- }, [debouncedSearchFeeds, query])
-
- useFocusEffect(
- React.useCallback(() => {
- store.shell.setMinimalShellMode(false)
- if (!feeds.hasLoaded) {
- feeds.refresh()
- }
- }, [store, feeds]),
- )
-
- const onRefresh = React.useCallback(() => {
- feeds.refresh()
- }, [feeds])
-
- const renderListEmptyComponent = () => {
- return (
-
-
- {feeds.isLoading
- ? isTabletOrDesktop
- ? 'Loading...'
- : ''
- : query
- ? `No results found for "${query}"`
- : `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
-
-
- )
- }
-
- const renderItem = React.useCallback(
- ({item}: {item: CustomFeedModel}) => (
-
- ),
- [],
- )
-
- return (
-
-
-
-
-
- item.data.uri}
- contentContainerStyle={styles.contentContainer}
- refreshControl={
-
- }
- renderItem={renderItem}
- initialNumToRender={10}
- ListEmptyComponent={renderListEmptyComponent}
- onEndReached={() => feeds.loadMore()}
- extraData={feeds.isLoading}
- />
-
- )
- }),
-)
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- contentContainer: {
- paddingBottom: 100,
- },
- containerDesktop: {
- borderLeftWidth: 1,
- borderRightWidth: 1,
- },
- empty: {
- paddingHorizontal: 16,
- paddingTop: 10,
- },
-})
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 97c6e86721..d2c4a6d2dd 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,90 +1,72 @@
import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import isEqual from 'lodash.isequal'
+import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {AtUri} from '@atproto/api'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {FlatList} from 'view/com/util/Views'
import {ViewHeader} from 'view/com/util/ViewHeader'
-import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Link} from 'view/com/util/Link'
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
-import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
-import {MultiFeed} from 'view/com/posts/MultiFeed'
import {usePalette} from 'lib/hooks/usePalette'
-import {useTimer} from 'lib/hooks/useTimer'
import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles'
-
-const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds
-const MOBILE_HEADER_OFFSET = 40
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import debounce from 'lodash.debounce'
+import {Text} from 'view/com/util/text/Text'
+import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds'
+import {FlatList} from 'view/com/util/Views'
+import {useFocusEffect} from '@react-navigation/native'
+import {CustomFeed} from 'view/com/feeds/CustomFeed'
type Props = NativeStackScreenProps
export const FeedsScreen = withAuthRequired(
observer(function FeedsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
- const {isMobile} = useWebMediaQueries()
- const flatListRef = React.useRef(null)
- const multifeed = React.useMemo(
- () => new PostsMultiFeedModel(store),
- [store],
+ const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+ const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store])
+ const [query, setQuery] = React.useState('')
+ const debouncedSearchFeeds = React.useMemo(
+ () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms
+ [myFeeds],
)
- const [onMainScroll, isScrolledDown, resetMainScroll] =
- useOnMainScroll(store)
- const [loadPromptVisible, setLoadPromptVisible] = React.useState(false)
- const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => {
- setLoadPromptVisible(true)
- })
-
- const onSoftReset = React.useCallback(() => {
- flatListRef.current?.scrollToOffset({offset: 0})
- multifeed.loadLatest()
- resetPromptTimer()
- setLoadPromptVisible(false)
- resetMainScroll()
- }, [
- flatListRef,
- resetMainScroll,
- multifeed,
- resetPromptTimer,
- setLoadPromptVisible,
- ])
useFocusEffect(
React.useCallback(() => {
- const softResetSub = store.onScreenSoftReset(onSoftReset)
- const multifeedCleanup = multifeed.registerListeners()
- const cleanup = () => {
- softResetSub.remove()
- multifeedCleanup()
- }
-
store.shell.setMinimalShellMode(false)
- return cleanup
- }, [store, multifeed, onSoftReset]),
+ myFeeds.setup()
+ }, [store.shell, myFeeds]),
)
- React.useEffect(() => {
- if (
- isEqual(
- multifeed.feedInfos.map(f => f.uri),
- store.me.savedFeeds.all.map(f => f.uri),
- )
- ) {
- // no changes
- return
- }
- multifeed.refresh()
- }, [multifeed, store.me.savedFeeds.all])
-
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
+ const onChangeQuery = React.useCallback(
+ (text: string) => {
+ setQuery(text)
+ if (text.length > 1) {
+ debouncedSearchFeeds(text)
+ } else {
+ myFeeds.discovery.refresh()
+ }
+ },
+ [debouncedSearchFeeds, myFeeds.discovery],
+ )
+ const onPressCancelSearch = React.useCallback(() => {
+ setQuery('')
+ myFeeds.discovery.refresh()
+ }, [myFeeds])
+ const onSubmitQuery = React.useCallback(() => {
+ debouncedSearchFeeds(query)
+ debouncedSearchFeeds.flush()
+ }, [debouncedSearchFeeds, query])
const renderHeaderBtn = React.useCallback(() => {
return (
@@ -99,30 +81,150 @@ export const FeedsScreen = withAuthRequired(
)
}, [pal])
+ const onRefresh = React.useCallback(() => {
+ myFeeds.refresh()
+ }, [myFeeds])
+
+ const renderItem = React.useCallback(
+ ({item}: {item: MyFeedsItem}) => {
+ if (item.type === 'discover-feeds-loading') {
+ return
+ } else if (item.type === 'spinner') {
+ return (
+
+
+
+ )
+ } else if (item.type === 'error') {
+ return
+ } else if (item.type === 'saved-feeds-header') {
+ if (!isMobile) {
+ return (
+
+
+ My Feeds
+
+
+
+
+
+ )
+ }
+ return
+ } else if (item.type === 'saved-feed') {
+ return (
+
+ )
+ } else if (item.type === 'discover-feeds-header') {
+ return (
+ <>
+
+
+ Discover new feeds
+
+ {!isMobile && (
+
+ )}
+
+ {isMobile && (
+
+
+
+ )}
+ >
+ )
+ } else if (item.type === 'discover-feed') {
+ return (
+
+ )
+ } else if (item.type === 'discover-feeds-no-results') {
+ return (
+
+
+ No results found for "{query}"
+
+
+ )
+ }
+ return null
+ },
+ [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery],
+ )
+
return (
-
{isMobile && (
)}
- {isScrolledDown || loadPromptVisible ? (
-
- ) : null}
+
+ item._reactKey}
+ contentContainerStyle={styles.contentContainer}
+ refreshControl={
+
+ }
+ renderItem={renderItem}
+ initialNumToRender={10}
+ onEndReached={() => myFeeds.loadMore()}
+ extraData={myFeeds.isLoading}
+ // @ts-ignore our .web version only -prf
+ desktopFixedHeight
+ />
+
+
+ {displayName}
+
+ {isMobile && (
+
+ )}
+
+ )
+}
+
const styles = StyleSheet.create({
container: {
flex: 1,
},
+ list: {
+ height: '100%',
+ },
+ contentContainer: {
+ paddingBottom: 100,
+ },
+
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 16,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+
+ savedFeed: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ gap: 12,
+ borderBottomWidth: 1,
+ },
+ savedFeedMobile: {
+ paddingVertical: 10,
+ },
})
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 33cc2e110b..60cda31db2 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,6 +1,8 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
@@ -8,6 +10,7 @@ import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {TextLink} from 'view/com/util/Link'
import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
@@ -16,14 +19,16 @@ import {FeedsTabBar} from '../com/pager/FeedsTabBar'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {FAB} from '../com/util/fab/FAB'
import {useStores} from 'state/index'
-import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s, colors} from 'lib/styles'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from 'lib/icons'
const HEADER_OFFSET_MOBILE = 78
-const HEADER_OFFSET_DESKTOP = 50
+const HEADER_OFFSET_TABLET = 50
+const HEADER_OFFSET_DESKTOP = 0
const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps
@@ -154,17 +159,23 @@ const FeedPage = observer(function FeedPageImpl({
renderEmptyState?: () => JSX.Element
}) {
const store = useStores()
- const {isMobile} = useWebMediaQueries()
+ const pal = usePalette('default')
+ const {isMobile, isTablet, isDesktop} = useWebMediaQueries()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
const {screen, track} = useAnalytics()
const [headerOffset, setHeaderOffset] = React.useState(
- isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
+ isMobile
+ ? HEADER_OFFSET_MOBILE
+ : isTablet
+ ? HEADER_OFFSET_TABLET
+ : HEADER_OFFSET_DESKTOP,
)
const scrollElRef = React.useRef(null)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isScreenFocused = useIsFocused()
+ const hasNew = feed.hasNewLatest && !feed.isRefreshing
React.useEffect(() => {
// called on first load
@@ -205,8 +216,14 @@ const FeedPage = observer(function FeedPageImpl({
// listens for resize events
React.useEffect(() => {
- setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
- }, [isMobile])
+ setHeaderOffset(
+ isMobile
+ ? HEADER_OFFSET_MOBILE
+ : isTablet
+ ? HEADER_OFFSET_TABLET
+ : HEADER_OFFSET_DESKTOP,
+ )
+ }, [isMobile, isTablet])
// fires when page within screen is activated/deactivated
// - check for latest
@@ -222,9 +239,6 @@ const FeedPage = observer(function FeedPageImpl({
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
feed.checkForLatest()
- if (feed.hasContent) {
- feed.update()
- }
return () => {
clearInterval(pollInterval)
@@ -247,7 +261,59 @@ const FeedPage = observer(function FeedPageImpl({
feed.refresh()
}, [feed, scrollToTop])
- const hasNew = feed.hasNewLatest && !feed.isRefreshing
+ const ListHeaderComponent = React.useCallback(() => {
+ if (isDesktop) {
+ return (
+
+
+ {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+ {hasNew && (
+
+ )}
+ >
+ }
+ onPress={() => store.emitScreenSoftReset()}
+ />
+
+ }
+ />
+
+ )
+ }
+ return <>>
+ }, [isDesktop, pal, store, hasNew])
+
return (
{(isScrolledDown || hasNew) && (
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 3c257fac89..243cc9596e 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -9,12 +9,15 @@ import {
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
+import {TextLink} from 'view/com/util/Link'
import {InvitedUsers} from '../com/notifications/InvitedUsers'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
-import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {isWeb} from 'platform/detection'
@@ -29,6 +32,12 @@ export const NotificationsScreen = withAuthRequired(
useOnMainScroll(store)
const scrollElRef = React.useRef(null)
const {screen} = useAnalytics()
+ const pal = usePalette('default')
+ const {isDesktop} = useWebMediaQueries()
+
+ const hasNew =
+ store.me.notifications.hasNewLatest &&
+ !store.me.notifications.isRefreshing
// event handlers
// =
@@ -88,9 +97,48 @@ export const NotificationsScreen = withAuthRequired(
),
)
- const hasNew =
- store.me.notifications.hasNewLatest &&
- !store.me.notifications.isRefreshing
+ const ListHeaderComponent = React.useCallback(() => {
+ if (isDesktop) {
+ return (
+
+
+ Notifications{' '}
+ {hasNew && (
+
+ )}
+ >
+ }
+ onPress={() => store.emitScreenSoftReset()}
+ />
+
+ )
+ }
+ return <>>
+ }, [isDesktop, pal, store, hasNew])
+
return (
@@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired(
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
+ ListHeaderComponent={ListHeaderComponent}
/>
{(isScrolledDown || hasNew) && (
-
- {value === 0
- ? `Show all replies`
- : `Show replies with at least ${value} ${
- value > 1 ? `likes` : `like`
- }`}
-
+
{
@@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
disabled={!enabled}
thumbTintColor={colors.blue3}
/>
+
+ {value === 0
+ ? `Show all replies`
+ : `Show replies with at least ${value} ${
+ value > 1 ? `likes` : `like`
+ }`}
+
)
}
@@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
Show Replies
- Adjust the number of likes a reply must have to be shown in your
- feed.
+ Set this setting to "No" to hide all replies from your feed.
-
+
+
+
+ Reply Filters
+
+
+ Enable this setting to only see replies between people you follow.
+
+
+
+ Adjust the number of likes a reply must have to be shown in your
+ feed.
+
@@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
onPress={store.preferences.toggleHomeFeedQuotePostsEnabled}
/>
+
+
+
+ Show Posts from My Feeds (Experimental)
+
+
+ Set this setting to "Yes" to show samples of your saved feeds in
+ your following feed.
+
+
+
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 69b5ceee69..241bae1ed6 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -69,9 +69,7 @@ export const ProfileScreen = withAuthRequired(
let aborted = false
store.shell.setMinimalShellMode(false)
const feedCleanup = uiState.feed.registerListeners()
- if (hasSetup) {
- uiState.update()
- } else {
+ if (!hasSetup) {
uiState.setup().then(() => {
if (aborted) {
return
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index d5c02ba638..5253c5bd69 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired(
return (
<>
-
+
-
+
- Saved Feeds
+ My Saved Feeds
) : (
-
)
}
- label="My Feeds"
- accessibilityLabel="My Feeds"
+ label="Feeds"
+ accessibilityLabel="Feeds"
accessibilityHint=""
onPress={onPressMyFeeds}
/>
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 4a34371ea4..8ba74da2ec 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -18,8 +18,7 @@ import {
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
- SatelliteDishIcon,
- SatelliteDishIconSolid,
+ HashtagIcon,
BellIcon,
BellIconSolid,
} from 'lib/icons'
@@ -134,16 +133,16 @@ export const BottomBar = observer(function BottomBarImpl({
testID="bottomBarFeedsBtn"
icon={
isAtFeeds ? (
-
) : (
-
)
}
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index f31ab44cf5..ae93814403 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -49,6 +49,9 @@ export const styles = StyleSheet.create({
homeIcon: {
top: 0,
},
+ feedsIcon: {
+ top: -2,
+ },
searchIcon: {
top: -2,
},
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index af70d33644..6448eea63f 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -15,8 +15,7 @@ import {
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
- SatelliteDishIcon,
- SatelliteDishIconSolid,
+ HashtagIcon,
UserIcon,
UserIconSolid,
} from 'lib/icons'
@@ -68,12 +67,11 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() {
{({isActive}) => {
- const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon
return (
-
)
}}
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
new file mode 100644
index 0000000000..4da1401c34
--- /dev/null
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import {View, StyleSheet} from 'react-native'
+import {useNavigationState} from '@react-navigation/native'
+import {AtUri} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {TextLink} from 'view/com/util/Link'
+import {getCurrentRoute} from 'lib/routes/helpers'
+
+export const DesktopFeeds = observer(function DesktopFeeds() {
+ const store = useStores()
+ const pal = usePalette('default')
+
+ const route = useNavigationState(state => {
+ if (!state) {
+ return {name: 'Home'}
+ }
+ return getCurrentRoute(state)
+ })
+
+ return (
+
+
+ {store.me.savedFeeds.pinned.map(feed => {
+ try {
+ const {hostname, rkey} = new AtUri(feed.uri)
+ const href = `/profile/${hostname}/feed/${rkey}`
+ const params = route.params as Record
+ return (
+
+ )
+ } catch {
+ return null
+ }
+ })}
+
+
+
+
+ )
+})
+
+function FeedItem({
+ title,
+ href,
+ current,
+}: {
+ title: string
+ href: string
+ current: boolean
+}) {
+ const pal = usePalette('default')
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'relative',
+ width: 300,
+ paddingHorizontal: 12,
+ borderTopWidth: 1,
+ borderBottomWidth: 1,
+ paddingVertical: 18,
+ },
+})
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 8c1a33245f..907df86419 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -32,8 +32,7 @@ import {
CogIconSolid,
ComposeIcon2,
HandIcon,
- SatelliteDishIcon,
- SatelliteDishIconSolid,
+ HashtagIcon,
} from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
@@ -272,20 +271,20 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
}
iconFilled={
-
}
- label="My Feeds"
+ label="Feeds"
/>
{store.session.hasSession && }
+ {store.session.hasSession && }
{store.session.isSandbox ? (
@@ -126,7 +128,7 @@ const styles = StyleSheet.create({
},
message: {
- marginTop: 20,
+ paddingVertical: 18,
paddingHorizontal: 10,
},
messageLine: {
@@ -134,7 +136,6 @@ const styles = StyleSheet.create({
},
inviteCodes: {
- marginTop: 12,
borderTopWidth: 1,
paddingHorizontal: 16,
paddingVertical: 12,
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index c7b322b581..dfd4f50bfc 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -113,6 +113,7 @@ const styles = StyleSheet.create({
container: {
position: 'relative',
width: 300,
+ paddingBottom: 18,
},
search: {
paddingHorizontal: 16,
diff --git a/yarn.lock b/yarn.lock
index 41b423366a..3ee7d4c0de 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6418,6 +6418,13 @@
dependencies:
"@types/lodash" "*"
+"@types/lodash.random@^3.2.7":
+ version "3.2.7"
+ resolved "https://registry.yarnpkg.com/@types/lodash.random/-/lodash.random-3.2.7.tgz#3100a1b7956ce86ab5adcce2e7b305412b98e3bf"
+ integrity sha512-gFKkVgWYi1q7RFJ+QNTzaRprdhVIZLpZd6C3MTNehKcujMn9SyFUqf2fTBOmvIYXqNk0RpwfbdOwHf0GnEQB0g==
+ dependencies:
+ "@types/lodash" "*"
+
"@types/lodash.samplesize@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f"
@@ -13886,6 +13893,11 @@ lodash.once@^4.0.0, lodash.once@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
+lodash.random@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.random/-/lodash.random-3.2.0.tgz#96e24e763333199130d2c9e2fd57f91703cc262d"
+ integrity sha512-A6Vn7teN0+qSnhOsE8yx2bGowCS1G7D9e5abq8VhwOP98YHS/KrGMf43yYxA05lvcvloT+W9Z2ffkSajFTcPUA==
+
lodash.samplesize@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9"
@@ -16855,10 +16867,10 @@ react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
-react-error-overlay@^6.0.11:
- version "6.0.11"
- resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
- integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
+react-error-overlay@6.0.9, react-error-overlay@^6.0.11:
+ version "6.0.9"
+ resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
+ integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
react-freeze@^1.0.0:
version "1.0.3"