mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add feeds tab

+937 -290
+1
bskyweb/cmd/bskyweb/server.go
··· 106 106 // generic routes 107 107 e.GET("/search", server.WebGeneric) 108 108 e.GET("/search/feeds", server.WebGeneric) 109 + e.GET("/feeds", server.WebGeneric) 109 110 e.GET("/notifications", server.WebGeneric) 110 111 e.GET("/moderation", server.WebGeneric) 111 112 e.GET("/moderation/mute-lists", server.WebGeneric)
+27 -1
src/Navigation.tsx
··· 14 14 import { 15 15 HomeTabNavigatorParams, 16 16 SearchTabNavigatorParams, 17 + FeedsTabNavigatorParams, 17 18 NotificationsTabNavigatorParams, 18 19 FlatNavigatorParams, 19 20 AllNavigatorParams, ··· 32 33 33 34 import {HomeScreen} from './view/screens/Home' 34 35 import {SearchScreen} from './view/screens/Search' 36 + import {FeedsScreen} from './view/screens/Feeds' 35 37 import {NotificationsScreen} from './view/screens/Notifications' 36 38 import {ModerationScreen} from './view/screens/Moderation' 37 39 import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' ··· 65 67 66 68 const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>() 67 69 const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>() 70 + const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>() 68 71 const NotificationsTab = 69 72 createNativeStackNavigator<NotificationsTabNavigatorParams>() 70 73 const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>() ··· 225 228 screenOptions={{headerShown: false}} 226 229 tabBar={tabBar}> 227 230 <Tab.Screen name="HomeTab" component={HomeTabNavigator} /> 231 + <Tab.Screen name="SearchTab" component={SearchTabNavigator} /> 232 + <Tab.Screen name="FeedsTab" component={FeedsTabNavigator} /> 228 233 <Tab.Screen 229 234 name="NotificationsTab" 230 235 component={NotificationsTabNavigator} 231 236 /> 232 - <Tab.Screen name="SearchTab" component={SearchTabNavigator} /> 233 237 <Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} /> 234 238 </Tab.Navigator> 235 239 ) ··· 269 273 ) 270 274 } 271 275 276 + function FeedsTabNavigator() { 277 + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) 278 + return ( 279 + <FeedsTab.Navigator 280 + screenOptions={{ 281 + gestureEnabled: true, 282 + fullScreenGestureEnabled: true, 283 + headerShown: false, 284 + animationDuration: 250, 285 + contentStyle, 286 + }}> 287 + <FeedsTab.Screen name="Feeds" component={FeedsScreen} /> 288 + {commonScreens(FeedsTab as typeof HomeTab)} 289 + </FeedsTab.Navigator> 290 + ) 291 + } 292 + 272 293 function NotificationsTabNavigator() { 273 294 const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) 274 295 return ( ··· 341 362 name="Search" 342 363 component={SearchScreen} 343 364 options={{title: title('Search')}} 365 + /> 366 + <Flat.Screen 367 + name="Feeds" 368 + component={FeedsScreen} 369 + options={{title: title('Feeds')}} 344 370 /> 345 371 <Flat.Screen 346 372 name="Notifications"
+3 -1
src/lib/hooks/useNavigationTabState.ts
··· 6 6 const res = { 7 7 isAtHome: getTabState(state, 'Home') !== TabState.Outside, 8 8 isAtSearch: getTabState(state, 'Search') !== TabState.Outside, 9 + isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside, 9 10 isAtNotifications: 10 11 getTabState(state, 'Notifications') !== TabState.Outside, 11 12 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, 12 13 } 13 14 if ( 14 15 !res.isAtHome && 16 + !res.isAtSearch && 17 + !res.isAtFeeds && 15 18 !res.isAtNotifications && 16 - !res.isAtSearch && 17 19 !res.isAtMyProfile 18 20 ) { 19 21 // HACK for some reason useNavigationState will give us pre-hydration results
+7
src/lib/routes/types.ts
··· 34 34 export type BottomTabNavigatorParams = CommonNavigatorParams & { 35 35 HomeTab: undefined 36 36 SearchTab: undefined 37 + FeedsTab: undefined 37 38 NotificationsTab: undefined 38 39 MyProfileTab: undefined 39 40 } ··· 44 45 45 46 export type SearchTabNavigatorParams = CommonNavigatorParams & { 46 47 Search: {q?: string} 48 + } 49 + 50 + export type FeedsTabNavigatorParams = CommonNavigatorParams & { 51 + Feeds: undefined 47 52 } 48 53 49 54 export type NotificationsTabNavigatorParams = CommonNavigatorParams & { ··· 65 70 Home: undefined 66 71 SearchTab: undefined 67 72 Search: {q?: string} 73 + FeedsTab: undefined 74 + Feeds: undefined 68 75 NotificationsTab: undefined 69 76 Notifications: undefined 70 77 MyProfileTab: undefined
+1
src/routes.ts
··· 3 3 export const router = new Router({ 4 4 Home: '/', 5 5 Search: '/search', 6 + Feeds: '/feeds', 6 7 DiscoverFeeds: '/search/feeds', 7 8 Notifications: '/notifications', 8 9 Settings: '/settings',
+216
src/state/models/feeds/multi-feed.ts
··· 1 + import {makeAutoObservable, runInAction} from 'mobx' 2 + import {AtUri} from '@atproto/api' 3 + import {bundleAsync} from 'lib/async/bundle' 4 + import {RootStoreModel} from '../root-store' 5 + import {CustomFeedModel} from './custom-feed' 6 + import {PostsFeedModel} from './posts' 7 + import {PostsFeedSliceModel} from './post' 8 + 9 + const FEED_PAGE_SIZE = 5 10 + const FEEDS_PAGE_SIZE = 3 11 + 12 + export type MultiFeedItem = 13 + | { 14 + _reactKey: string 15 + type: 'header' 16 + } 17 + | { 18 + _reactKey: string 19 + type: 'feed-header' 20 + avatar: string | undefined 21 + title: string 22 + } 23 + | { 24 + _reactKey: string 25 + type: 'feed-slice' 26 + slice: PostsFeedSliceModel 27 + } 28 + | { 29 + _reactKey: string 30 + type: 'feed-loading' 31 + } 32 + | { 33 + _reactKey: string 34 + type: 'feed-error' 35 + error: string 36 + } 37 + | { 38 + _reactKey: string 39 + type: 'feed-footer' 40 + title: string 41 + uri: string 42 + } 43 + | { 44 + _reactKey: string 45 + type: 'footer' 46 + } 47 + 48 + export class PostsMultiFeedModel { 49 + // state 50 + isLoading = false 51 + isRefreshing = false 52 + hasLoaded = false 53 + hasMore = true 54 + 55 + // data 56 + feedInfos: CustomFeedModel[] = [] 57 + feeds: PostsFeedModel[] = [] 58 + 59 + constructor(public rootStore: RootStoreModel) { 60 + makeAutoObservable(this, {rootStore: false}, {autoBind: true}) 61 + } 62 + 63 + get hasContent() { 64 + return this.feeds.length !== 0 65 + } 66 + 67 + get isEmpty() { 68 + return this.hasLoaded && !this.hasContent 69 + } 70 + 71 + get items() { 72 + const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] 73 + for (let i = 0; i < this.feedInfos.length; i++) { 74 + if (!this.feeds[i]) { 75 + break 76 + } 77 + const feed = this.feeds[i] 78 + const feedInfo = this.feedInfos[i] 79 + const urip = new AtUri(feedInfo.uri) 80 + items.push({ 81 + _reactKey: `__feed_header_${i}__`, 82 + type: 'feed-header', 83 + avatar: feedInfo.data.avatar, 84 + title: feedInfo.displayName, 85 + }) 86 + if (feed.isLoading) { 87 + items.push({ 88 + _reactKey: `__feed_loading_${i}__`, 89 + type: 'feed-loading', 90 + }) 91 + } else if (feed.hasError) { 92 + items.push({ 93 + _reactKey: `__feed_error_${i}__`, 94 + type: 'feed-error', 95 + error: feed.error, 96 + }) 97 + } else { 98 + for (let j = 0; j < feed.slices.length; j++) { 99 + items.push({ 100 + _reactKey: `__feed_slice_${i}_${j}__`, 101 + type: 'feed-slice', 102 + slice: feed.slices[j], 103 + }) 104 + } 105 + } 106 + items.push({ 107 + _reactKey: `__feed_footer_${i}__`, 108 + type: 'feed-footer', 109 + title: feedInfo.displayName, 110 + uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`, 111 + }) 112 + } 113 + if (!this.hasMore) { 114 + items.push({_reactKey: '__footer__', type: 'footer'}) 115 + } 116 + return items 117 + } 118 + 119 + // public api 120 + // = 121 + 122 + /** 123 + * Nuke all data 124 + */ 125 + clear() { 126 + this.rootStore.log.debug('MultiFeedModel:clear') 127 + this.isLoading = false 128 + this.isRefreshing = false 129 + this.hasLoaded = false 130 + this.hasMore = true 131 + this.feeds = [] 132 + } 133 + 134 + /** 135 + * Register any event listeners. Returns a cleanup function. 136 + */ 137 + registerListeners() { 138 + const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) 139 + return () => sub.remove() 140 + } 141 + 142 + /** 143 + * Reset and load 144 + */ 145 + async refresh() { 146 + this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds 147 + await this.loadMore(true) 148 + } 149 + 150 + /** 151 + * Load more posts to the end of the feed 152 + */ 153 + loadMore = bundleAsync(async (isRefreshing: boolean = false) => { 154 + if (!isRefreshing && !this.hasMore) { 155 + return 156 + } 157 + if (isRefreshing) { 158 + this.isRefreshing = true // set optimistically for UI 159 + this.feeds = [] 160 + } 161 + this._xLoading(isRefreshing) 162 + const start = this.feeds.length 163 + const newFeeds: PostsFeedModel[] = [] 164 + for ( 165 + let i = start; 166 + i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; 167 + i++ 168 + ) { 169 + const feed = new PostsFeedModel(this.rootStore, 'custom', { 170 + feed: this.feedInfos[i].uri, 171 + }) 172 + feed.pageSize = FEED_PAGE_SIZE 173 + await feed.setup() 174 + newFeeds.push(feed) 175 + } 176 + runInAction(() => { 177 + this.feeds = this.feeds.concat(newFeeds) 178 + this.hasMore = this.feeds.length < this.feedInfos.length 179 + }) 180 + this._xIdle() 181 + }) 182 + 183 + /** 184 + * Attempt to load more again after a failure 185 + */ 186 + async retryLoadMore() { 187 + this.hasMore = true 188 + return this.loadMore() 189 + } 190 + 191 + /** 192 + * Removes posts from the feed upon deletion. 193 + */ 194 + onPostDeleted(uri: string) { 195 + for (const f of this.feeds) { 196 + f.onPostDeleted(uri) 197 + } 198 + } 199 + 200 + // state transitions 201 + // = 202 + 203 + _xLoading(isRefreshing = false) { 204 + this.isLoading = true 205 + this.isRefreshing = isRefreshing 206 + } 207 + 208 + _xIdle() { 209 + this.isLoading = false 210 + this.isRefreshing = false 211 + this.hasLoaded = true 212 + } 213 + 214 + // helper functions 215 + // = 216 + }
+265
src/state/models/feeds/post.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' 3 + import {RootStoreModel} from '../root-store' 4 + import {updateDataOptimistically} from 'lib/async/revertible' 5 + import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 6 + import {FeedViewPostsSlice} from 'lib/api/feed-manip' 7 + import { 8 + getEmbedLabels, 9 + getEmbedMuted, 10 + getEmbedMutedByList, 11 + getEmbedBlocking, 12 + getEmbedBlockedBy, 13 + getPostModeration, 14 + filterAccountLabels, 15 + filterProfileLabels, 16 + mergePostModerations, 17 + } from 'lib/labeling/helpers' 18 + 19 + type FeedViewPost = AppBskyFeedDefs.FeedViewPost 20 + type ReasonRepost = AppBskyFeedDefs.ReasonRepost 21 + type PostView = AppBskyFeedDefs.PostView 22 + 23 + let _idCounter = 0 24 + 25 + export class PostsFeedItemModel { 26 + // ui state 27 + _reactKey: string = '' 28 + 29 + // data 30 + post: PostView 31 + postRecord?: AppBskyFeedPost.Record 32 + reply?: FeedViewPost['reply'] 33 + reason?: FeedViewPost['reason'] 34 + richText?: RichText 35 + 36 + constructor( 37 + public rootStore: RootStoreModel, 38 + reactKey: string, 39 + v: FeedViewPost, 40 + ) { 41 + this._reactKey = reactKey 42 + this.post = v.post 43 + if (AppBskyFeedPost.isRecord(this.post.record)) { 44 + const valid = AppBskyFeedPost.validateRecord(this.post.record) 45 + if (valid.success) { 46 + this.postRecord = this.post.record 47 + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) 48 + } else { 49 + this.postRecord = undefined 50 + this.richText = undefined 51 + rootStore.log.warn( 52 + 'Received an invalid app.bsky.feed.post record', 53 + valid.error, 54 + ) 55 + } 56 + } else { 57 + this.postRecord = undefined 58 + this.richText = undefined 59 + rootStore.log.warn( 60 + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', 61 + this.post.record, 62 + ) 63 + } 64 + this.reply = v.reply 65 + this.reason = v.reason 66 + makeAutoObservable(this, {rootStore: false}) 67 + } 68 + 69 + get rootUri(): string { 70 + if (this.reply?.root.uri) { 71 + return this.reply.root.uri 72 + } 73 + return this.post.uri 74 + } 75 + 76 + get isThreadMuted() { 77 + return this.rootStore.mutedThreads.uris.has(this.rootUri) 78 + } 79 + 80 + get labelInfo(): PostLabelInfo { 81 + return { 82 + postLabels: (this.post.labels || []).concat( 83 + getEmbedLabels(this.post.embed), 84 + ), 85 + accountLabels: filterAccountLabels(this.post.author.labels), 86 + profileLabels: filterProfileLabels(this.post.author.labels), 87 + isMuted: 88 + this.post.author.viewer?.muted || 89 + getEmbedMuted(this.post.embed) || 90 + false, 91 + mutedByList: 92 + this.post.author.viewer?.mutedByList || 93 + getEmbedMutedByList(this.post.embed), 94 + isBlocking: 95 + !!this.post.author.viewer?.blocking || 96 + getEmbedBlocking(this.post.embed) || 97 + false, 98 + isBlockedBy: 99 + !!this.post.author.viewer?.blockedBy || 100 + getEmbedBlockedBy(this.post.embed) || 101 + false, 102 + } 103 + } 104 + 105 + get moderation(): PostModeration { 106 + return getPostModeration(this.rootStore, this.labelInfo) 107 + } 108 + 109 + copy(v: FeedViewPost) { 110 + this.post = v.post 111 + this.reply = v.reply 112 + this.reason = v.reason 113 + } 114 + 115 + copyMetrics(v: FeedViewPost) { 116 + this.post.replyCount = v.post.replyCount 117 + this.post.repostCount = v.post.repostCount 118 + this.post.likeCount = v.post.likeCount 119 + this.post.viewer = v.post.viewer 120 + } 121 + 122 + get reasonRepost(): ReasonRepost | undefined { 123 + if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { 124 + return this.reason as ReasonRepost 125 + } 126 + } 127 + 128 + async toggleLike() { 129 + this.post.viewer = this.post.viewer || {} 130 + if (this.post.viewer.like) { 131 + const url = this.post.viewer.like 132 + await updateDataOptimistically( 133 + this.post, 134 + () => { 135 + this.post.likeCount = (this.post.likeCount || 0) - 1 136 + this.post.viewer!.like = undefined 137 + }, 138 + () => this.rootStore.agent.deleteLike(url), 139 + ) 140 + } else { 141 + await updateDataOptimistically( 142 + this.post, 143 + () => { 144 + this.post.likeCount = (this.post.likeCount || 0) + 1 145 + this.post.viewer!.like = 'pending' 146 + }, 147 + () => this.rootStore.agent.like(this.post.uri, this.post.cid), 148 + res => { 149 + this.post.viewer!.like = res.uri 150 + }, 151 + ) 152 + } 153 + } 154 + 155 + async toggleRepost() { 156 + this.post.viewer = this.post.viewer || {} 157 + if (this.post.viewer?.repost) { 158 + const url = this.post.viewer.repost 159 + await updateDataOptimistically( 160 + this.post, 161 + () => { 162 + this.post.repostCount = (this.post.repostCount || 0) - 1 163 + this.post.viewer!.repost = undefined 164 + }, 165 + () => this.rootStore.agent.deleteRepost(url), 166 + ) 167 + } else { 168 + await updateDataOptimistically( 169 + this.post, 170 + () => { 171 + this.post.repostCount = (this.post.repostCount || 0) + 1 172 + this.post.viewer!.repost = 'pending' 173 + }, 174 + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), 175 + res => { 176 + this.post.viewer!.repost = res.uri 177 + }, 178 + ) 179 + } 180 + } 181 + 182 + async toggleThreadMute() { 183 + if (this.isThreadMuted) { 184 + this.rootStore.mutedThreads.uris.delete(this.rootUri) 185 + } else { 186 + this.rootStore.mutedThreads.uris.add(this.rootUri) 187 + } 188 + } 189 + 190 + async delete() { 191 + await this.rootStore.agent.deletePost(this.post.uri) 192 + this.rootStore.emitPostDeleted(this.post.uri) 193 + } 194 + } 195 + 196 + export class PostsFeedSliceModel { 197 + // ui state 198 + _reactKey: string = '' 199 + 200 + // data 201 + items: PostsFeedItemModel[] = [] 202 + 203 + constructor( 204 + public rootStore: RootStoreModel, 205 + reactKey: string, 206 + slice: FeedViewPostsSlice, 207 + ) { 208 + this._reactKey = reactKey 209 + for (const item of slice.items) { 210 + this.items.push( 211 + new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item), 212 + ) 213 + } 214 + makeAutoObservable(this, {rootStore: false}) 215 + } 216 + 217 + get uri() { 218 + if (this.isReply) { 219 + return this.items[1].post.uri 220 + } 221 + return this.items[0].post.uri 222 + } 223 + 224 + get isThread() { 225 + return ( 226 + this.items.length > 1 && 227 + this.items.every( 228 + item => item.post.author.did === this.items[0].post.author.did, 229 + ) 230 + ) 231 + } 232 + 233 + get isReply() { 234 + return this.items.length > 1 && !this.isThread 235 + } 236 + 237 + get rootItem() { 238 + if (this.isReply) { 239 + return this.items[1] 240 + } 241 + return this.items[0] 242 + } 243 + 244 + get moderation() { 245 + return mergePostModerations(this.items.map(item => item.moderation)) 246 + } 247 + 248 + containsUri(uri: string) { 249 + return !!this.items.find(item => item.post.uri === uri) 250 + } 251 + 252 + isThreadParentAt(i: number) { 253 + if (this.items.length === 1) { 254 + return false 255 + } 256 + return i < this.items.length - 1 257 + } 258 + 259 + isThreadChildAt(i: number) { 260 + if (this.items.length === 1) { 261 + return false 262 + } 263 + return i > 0 264 + } 265 + }
+5 -265
src/state/models/feeds/posts.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import { 3 3 AppBskyFeedGetTimeline as GetTimeline, 4 - AppBskyFeedDefs, 5 - AppBskyFeedPost, 6 4 AppBskyFeedGetAuthorFeed as GetAuthorFeed, 7 5 AppBskyFeedGetFeed as GetCustomFeed, 8 - RichText, 9 6 } from '@atproto/api' 10 7 import AwaitLock from 'await-lock' 11 8 import {bundleAsync} from 'lib/async/bundle' ··· 19 16 mergePosts, 20 17 } from 'lib/api/build-suggested-posts' 21 18 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 22 - import {updateDataOptimistically} from 'lib/async/revertible' 23 - import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 24 - import { 25 - getEmbedLabels, 26 - getEmbedMuted, 27 - getEmbedMutedByList, 28 - getEmbedBlocking, 29 - getEmbedBlockedBy, 30 - getPostModeration, 31 - mergePostModerations, 32 - filterAccountLabels, 33 - filterProfileLabels, 34 - } from 'lib/labeling/helpers' 35 - 36 - type FeedViewPost = AppBskyFeedDefs.FeedViewPost 37 - type ReasonRepost = AppBskyFeedDefs.ReasonRepost 38 - type PostView = AppBskyFeedDefs.PostView 19 + import {PostsFeedSliceModel} from './post' 39 20 40 21 const PAGE_SIZE = 30 41 22 let _idCounter = 0 42 23 43 - export class PostsFeedItemModel { 44 - // ui state 45 - _reactKey: string = '' 46 - 47 - // data 48 - post: PostView 49 - postRecord?: AppBskyFeedPost.Record 50 - reply?: FeedViewPost['reply'] 51 - reason?: FeedViewPost['reason'] 52 - richText?: RichText 53 - 54 - constructor( 55 - public rootStore: RootStoreModel, 56 - reactKey: string, 57 - v: FeedViewPost, 58 - ) { 59 - this._reactKey = reactKey 60 - this.post = v.post 61 - if (AppBskyFeedPost.isRecord(this.post.record)) { 62 - const valid = AppBskyFeedPost.validateRecord(this.post.record) 63 - if (valid.success) { 64 - this.postRecord = this.post.record 65 - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) 66 - } else { 67 - this.postRecord = undefined 68 - this.richText = undefined 69 - rootStore.log.warn( 70 - 'Received an invalid app.bsky.feed.post record', 71 - valid.error, 72 - ) 73 - } 74 - } else { 75 - this.postRecord = undefined 76 - this.richText = undefined 77 - rootStore.log.warn( 78 - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', 79 - this.post.record, 80 - ) 81 - } 82 - this.reply = v.reply 83 - this.reason = v.reason 84 - makeAutoObservable(this, {rootStore: false}) 85 - } 86 - 87 - get rootUri(): string { 88 - if (this.reply?.root.uri) { 89 - return this.reply.root.uri 90 - } 91 - return this.post.uri 92 - } 93 - 94 - get isThreadMuted() { 95 - return this.rootStore.mutedThreads.uris.has(this.rootUri) 96 - } 97 - 98 - get labelInfo(): PostLabelInfo { 99 - return { 100 - postLabels: (this.post.labels || []).concat( 101 - getEmbedLabels(this.post.embed), 102 - ), 103 - accountLabels: filterAccountLabels(this.post.author.labels), 104 - profileLabels: filterProfileLabels(this.post.author.labels), 105 - isMuted: 106 - this.post.author.viewer?.muted || 107 - getEmbedMuted(this.post.embed) || 108 - false, 109 - mutedByList: 110 - this.post.author.viewer?.mutedByList || 111 - getEmbedMutedByList(this.post.embed), 112 - isBlocking: 113 - !!this.post.author.viewer?.blocking || 114 - getEmbedBlocking(this.post.embed) || 115 - false, 116 - isBlockedBy: 117 - !!this.post.author.viewer?.blockedBy || 118 - getEmbedBlockedBy(this.post.embed) || 119 - false, 120 - } 121 - } 122 - 123 - get moderation(): PostModeration { 124 - return getPostModeration(this.rootStore, this.labelInfo) 125 - } 126 - 127 - copy(v: FeedViewPost) { 128 - this.post = v.post 129 - this.reply = v.reply 130 - this.reason = v.reason 131 - } 132 - 133 - copyMetrics(v: FeedViewPost) { 134 - this.post.replyCount = v.post.replyCount 135 - this.post.repostCount = v.post.repostCount 136 - this.post.likeCount = v.post.likeCount 137 - this.post.viewer = v.post.viewer 138 - } 139 - 140 - get reasonRepost(): ReasonRepost | undefined { 141 - if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { 142 - return this.reason as ReasonRepost 143 - } 144 - } 145 - 146 - async toggleLike() { 147 - this.post.viewer = this.post.viewer || {} 148 - if (this.post.viewer.like) { 149 - const url = this.post.viewer.like 150 - await updateDataOptimistically( 151 - this.post, 152 - () => { 153 - this.post.likeCount = (this.post.likeCount || 0) - 1 154 - this.post.viewer!.like = undefined 155 - }, 156 - () => this.rootStore.agent.deleteLike(url), 157 - ) 158 - } else { 159 - await updateDataOptimistically( 160 - this.post, 161 - () => { 162 - this.post.likeCount = (this.post.likeCount || 0) + 1 163 - this.post.viewer!.like = 'pending' 164 - }, 165 - () => this.rootStore.agent.like(this.post.uri, this.post.cid), 166 - res => { 167 - this.post.viewer!.like = res.uri 168 - }, 169 - ) 170 - } 171 - } 172 - 173 - async toggleRepost() { 174 - this.post.viewer = this.post.viewer || {} 175 - if (this.post.viewer?.repost) { 176 - const url = this.post.viewer.repost 177 - await updateDataOptimistically( 178 - this.post, 179 - () => { 180 - this.post.repostCount = (this.post.repostCount || 0) - 1 181 - this.post.viewer!.repost = undefined 182 - }, 183 - () => this.rootStore.agent.deleteRepost(url), 184 - ) 185 - } else { 186 - await updateDataOptimistically( 187 - this.post, 188 - () => { 189 - this.post.repostCount = (this.post.repostCount || 0) + 1 190 - this.post.viewer!.repost = 'pending' 191 - }, 192 - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), 193 - res => { 194 - this.post.viewer!.repost = res.uri 195 - }, 196 - ) 197 - } 198 - } 199 - 200 - async toggleThreadMute() { 201 - if (this.isThreadMuted) { 202 - this.rootStore.mutedThreads.uris.delete(this.rootUri) 203 - } else { 204 - this.rootStore.mutedThreads.uris.add(this.rootUri) 205 - } 206 - } 207 - 208 - async delete() { 209 - await this.rootStore.agent.deletePost(this.post.uri) 210 - this.rootStore.emitPostDeleted(this.post.uri) 211 - } 212 - } 213 - 214 - export class PostsFeedSliceModel { 215 - // ui state 216 - _reactKey: string = '' 217 - 218 - // data 219 - items: PostsFeedItemModel[] = [] 220 - 221 - constructor( 222 - public rootStore: RootStoreModel, 223 - reactKey: string, 224 - slice: FeedViewPostsSlice, 225 - ) { 226 - this._reactKey = reactKey 227 - for (const item of slice.items) { 228 - this.items.push( 229 - new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item), 230 - ) 231 - } 232 - makeAutoObservable(this, {rootStore: false}) 233 - } 234 - 235 - get uri() { 236 - if (this.isReply) { 237 - return this.items[1].post.uri 238 - } 239 - return this.items[0].post.uri 240 - } 241 - 242 - get isThread() { 243 - return ( 244 - this.items.length > 1 && 245 - this.items.every( 246 - item => item.post.author.did === this.items[0].post.author.did, 247 - ) 248 - ) 249 - } 250 - 251 - get isReply() { 252 - return this.items.length > 1 && !this.isThread 253 - } 254 - 255 - get rootItem() { 256 - if (this.isReply) { 257 - return this.items[1] 258 - } 259 - return this.items[0] 260 - } 261 - 262 - get moderation() { 263 - return mergePostModerations(this.items.map(item => item.moderation)) 264 - } 265 - 266 - containsUri(uri: string) { 267 - return !!this.items.find(item => item.post.uri === uri) 268 - } 269 - 270 - isThreadParentAt(i: number) { 271 - if (this.items.length === 1) { 272 - return false 273 - } 274 - return i < this.items.length - 1 275 - } 276 - 277 - isThreadChildAt(i: number) { 278 - if (this.items.length === 1) { 279 - return false 280 - } 281 - return i > 0 282 - } 283 - } 284 - 285 24 export class PostsFeedModel { 286 25 // state 287 26 isLoading = false ··· 297 36 loadMoreCursor: string | undefined 298 37 pollCursor: string | undefined 299 38 tuner = new FeedTuner() 39 + pageSize = PAGE_SIZE 300 40 301 41 // used to linearize async modifications to state 302 42 lock = new AwaitLock() ··· 418 158 this.tuner.reset() 419 159 this._xLoading(isRefreshing) 420 160 try { 421 - const res = await this._getFeed({limit: PAGE_SIZE}) 161 + const res = await this._getFeed({limit: this.pageSize}) 422 162 await this._replaceAll(res) 423 163 this._xIdle() 424 164 } catch (e: any) { ··· 457 197 try { 458 198 const res = await this._getFeed({ 459 199 cursor: this.loadMoreCursor, 460 - limit: PAGE_SIZE, 200 + limit: this.pageSize, 461 201 }) 462 202 await this._appendAll(res) 463 203 this._xIdle() ··· 526 266 if (this.hasNewLatest || this.feedType === 'suggested') { 527 267 return 528 268 } 529 - const res = await this._getFeed({limit: PAGE_SIZE}) 269 + const res = await this._getFeed({limit: this.pageSize}) 530 270 const tuner = new FeedTuner() 531 271 const slices = tuner.tune(res.data.feed, this.feedTuners) 532 272 this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
+2 -6
src/view/com/pager/FeedsTabBarMobile.tsx
··· 8 8 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 9 9 import {Link} from '../util/Link' 10 10 import {Text} from '../util/text/Text' 11 - import {SatelliteDishIcon} from 'lib/icons' 11 + import {CogIcon} from 'lib/icons' 12 12 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 13 import {s} from 'lib/styles' 14 14 ··· 69 69 accessibilityRole="button" 70 70 accessibilityLabel="Edit Saved Feeds" 71 71 accessibilityHint="Opens screen to edit Saved Feeds"> 72 - <SatelliteDishIcon 73 - size={20} 74 - strokeWidth={2} 75 - style={pal.textLight} 76 - /> 72 + <CogIcon size={21} strokeWidth={2} style={pal.textLight} /> 77 73 </Link> 78 74 </View> 79 75 </View>
+1 -1
src/view/com/pager/TabBar.tsx
··· 131 131 backgroundColor: 'transparent', 132 132 }, 133 133 contentContainer: { 134 - columnGap: 16, 134 + columnGap: 20, 135 135 marginLeft: 18, 136 136 paddingRight: 28, 137 137 backgroundColor: 'transparent',
-2
src/view/com/posts/Feed.tsx
··· 33 33 onPressTryAgain, 34 34 onScroll, 35 35 scrollEventThrottle, 36 - onMomentumScrollEnd, 37 36 renderEmptyState, 38 37 testID, 39 38 headerOffset = 0, ··· 186 185 style={{paddingTop: headerOffset}} 187 186 onScroll={onScroll} 188 187 scrollEventThrottle={scrollEventThrottle} 189 - onMomentumScrollEnd={onMomentumScrollEnd} 190 188 indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} 191 189 onEndReached={onEndReached} 192 190 onEndReachedThreshold={0.6}
+230
src/view/com/posts/MultiFeed.tsx
··· 1 + import React, {MutableRefObject} from 'react' 2 + import {observer} from 'mobx-react-lite' 3 + import { 4 + ActivityIndicator, 5 + RefreshControl, 6 + StyleProp, 7 + StyleSheet, 8 + View, 9 + ViewStyle, 10 + } from 'react-native' 11 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 + import {FlatList} from '../util/Views' 13 + import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 14 + import {ErrorMessage} from '../util/error/ErrorMessage' 15 + import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed' 16 + import {FeedSlice} from './FeedSlice' 17 + import {Text} from '../util/text/Text' 18 + import {Link} from '../util/Link' 19 + import {UserAvatar} from '../util/UserAvatar' 20 + import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 21 + import {s} from 'lib/styles' 22 + import {useAnalytics} from 'lib/analytics' 23 + import {usePalette} from 'lib/hooks/usePalette' 24 + import {useTheme} from 'lib/ThemeContext' 25 + 26 + export const MultiFeed = observer(function Feed({ 27 + multifeed, 28 + style, 29 + showPostFollowBtn, 30 + scrollElRef, 31 + onScroll, 32 + scrollEventThrottle, 33 + testID, 34 + headerOffset = 0, 35 + extraData, 36 + }: { 37 + multifeed: PostsMultiFeedModel 38 + style?: StyleProp<ViewStyle> 39 + showPostFollowBtn?: boolean 40 + scrollElRef?: MutableRefObject<FlatList<any> | null> 41 + onPressTryAgain?: () => void 42 + onScroll?: OnScrollCb 43 + scrollEventThrottle?: number 44 + renderEmptyState?: () => JSX.Element 45 + testID?: string 46 + headerOffset?: number 47 + extraData?: any 48 + }) { 49 + const pal = usePalette('default') 50 + const palInverted = usePalette('inverted') 51 + const theme = useTheme() 52 + const {track} = useAnalytics() 53 + const [isRefreshing, setIsRefreshing] = React.useState(false) 54 + 55 + // events 56 + // = 57 + 58 + const onRefresh = React.useCallback(async () => { 59 + track('MultiFeed:onRefresh') 60 + setIsRefreshing(true) 61 + try { 62 + await multifeed.refresh() 63 + } catch (err) { 64 + multifeed.rootStore.log.error('Failed to refresh posts feed', err) 65 + } 66 + setIsRefreshing(false) 67 + }, [multifeed, track, setIsRefreshing]) 68 + 69 + const onEndReached = React.useCallback(async () => { 70 + track('MultiFeed:onEndReached') 71 + try { 72 + await multifeed.loadMore() 73 + } catch (err) { 74 + multifeed.rootStore.log.error('Failed to load more posts', err) 75 + } 76 + }, [multifeed, track]) 77 + 78 + // rendering 79 + // = 80 + 81 + const renderItem = React.useCallback( 82 + ({item}: {item: MultiFeedItem}) => { 83 + if (item.type === 'header') { 84 + return <View style={[styles.header, pal.border]} /> 85 + } else if (item.type === 'feed-header') { 86 + return ( 87 + <View style={styles.feedHeader}> 88 + <UserAvatar type="algo" avatar={item.avatar} size={28} /> 89 + <Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}> 90 + {item.title} 91 + </Text> 92 + </View> 93 + ) 94 + } else if (item.type === 'feed-slice') { 95 + return ( 96 + <FeedSlice slice={item.slice} showFollowBtn={showPostFollowBtn} /> 97 + ) 98 + } else if (item.type === 'feed-loading') { 99 + return <PostFeedLoadingPlaceholder /> 100 + } else if (item.type === 'feed-error') { 101 + return <ErrorMessage message={item.error} /> 102 + } else if (item.type === 'feed-footer') { 103 + return ( 104 + <Link 105 + href={item.uri} 106 + style={[styles.feedFooter, pal.border, pal.view]}> 107 + <Text type="lg" style={pal.link}> 108 + See more from {item.title} 109 + </Text> 110 + <FontAwesomeIcon 111 + icon="angle-right" 112 + size={18} 113 + color={pal.colors.link} 114 + /> 115 + </Link> 116 + ) 117 + } else if (item.type === 'footer') { 118 + return ( 119 + <Link 120 + style={[styles.footerLink, palInverted.view]} 121 + href="/search/feeds"> 122 + <FontAwesomeIcon 123 + icon="search" 124 + size={18} 125 + color={palInverted.colors.text} 126 + /> 127 + <Text type="lg-medium" style={palInverted.text}> 128 + Discover new feeds 129 + </Text> 130 + </Link> 131 + ) 132 + } 133 + return null 134 + }, 135 + [showPostFollowBtn, pal, palInverted], 136 + ) 137 + 138 + const FeedFooter = React.useCallback( 139 + () => 140 + multifeed.isLoading && !isRefreshing ? ( 141 + <View style={styles.loadMore}> 142 + <ActivityIndicator color={pal.colors.text} /> 143 + </View> 144 + ) : ( 145 + <View /> 146 + ), 147 + [multifeed.isLoading, isRefreshing, pal], 148 + ) 149 + 150 + return ( 151 + <View testID={testID} style={style}> 152 + {multifeed.items.length > 0 && ( 153 + <FlatList 154 + testID={testID ? `${testID}-flatlist` : undefined} 155 + ref={scrollElRef} 156 + data={multifeed.items} 157 + keyExtractor={item => item._reactKey} 158 + renderItem={renderItem} 159 + ListFooterComponent={FeedFooter} 160 + refreshControl={ 161 + <RefreshControl 162 + refreshing={isRefreshing} 163 + onRefresh={onRefresh} 164 + tintColor={pal.colors.text} 165 + titleColor={pal.colors.text} 166 + progressViewOffset={headerOffset} 167 + /> 168 + } 169 + contentContainerStyle={s.contentContainer} 170 + style={[{paddingTop: headerOffset}, pal.viewLight, styles.container]} 171 + onScroll={onScroll} 172 + scrollEventThrottle={scrollEventThrottle} 173 + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} 174 + onEndReached={onEndReached} 175 + onEndReachedThreshold={0.6} 176 + removeClippedSubviews={true} 177 + contentOffset={{x: 0, y: headerOffset * -1}} 178 + extraData={extraData} 179 + // @ts-ignore our .web version only -prf 180 + desktopFixedHeight 181 + /> 182 + )} 183 + </View> 184 + ) 185 + }) 186 + 187 + const styles = StyleSheet.create({ 188 + container: { 189 + height: '100%', 190 + }, 191 + header: { 192 + borderTopWidth: 1, 193 + marginBottom: 4, 194 + }, 195 + feedHeader: { 196 + flexDirection: 'row', 197 + gap: 8, 198 + alignItems: 'center', 199 + paddingHorizontal: 16, 200 + paddingBottom: 8, 201 + marginTop: 12, 202 + }, 203 + feedHeaderTitle: { 204 + fontWeight: 'bold', 205 + }, 206 + feedFooter: { 207 + flexDirection: 'row', 208 + justifyContent: 'space-between', 209 + alignItems: 'center', 210 + paddingHorizontal: 16, 211 + paddingVertical: 16, 212 + marginBottom: 12, 213 + borderTopWidth: 1, 214 + borderBottomWidth: 1, 215 + }, 216 + footerLink: { 217 + flexDirection: 'row', 218 + alignItems: 'center', 219 + justifyContent: 'center', 220 + borderRadius: 8, 221 + paddingHorizontal: 14, 222 + paddingVertical: 12, 223 + marginHorizontal: 8, 224 + marginBottom: 8, 225 + gap: 8, 226 + }, 227 + loadMore: { 228 + paddingTop: 10, 229 + }, 230 + })
+125
src/view/screens/Feeds.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import isEqual from 'lodash.isequal' 5 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 6 + import {FlatList} from 'view/com/util/Views' 7 + import {ViewHeader} from 'view/com/util/ViewHeader' 8 + import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 9 + import {FAB} from 'view/com/util/fab/FAB' 10 + import {Link} from 'view/com/util/Link' 11 + import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' 12 + import {observer} from 'mobx-react-lite' 13 + import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' 14 + import {MultiFeed} from 'view/com/posts/MultiFeed' 15 + import {isDesktopWeb} from 'platform/detection' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + import {useStores} from 'state/index' 18 + import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 19 + import {ComposeIcon2, CogIcon} from 'lib/icons' 20 + import {s} from 'lib/styles' 21 + 22 + const HEADER_OFFSET = isDesktopWeb ? 0 : 40 23 + 24 + type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> 25 + export const FeedsScreen = withAuthRequired( 26 + observer<Props>(({}: Props) => { 27 + const pal = usePalette('default') 28 + const store = useStores() 29 + const flatListRef = React.useRef<FlatList>(null) 30 + const multifeed = React.useMemo<PostsMultiFeedModel>( 31 + () => new PostsMultiFeedModel(store), 32 + [store], 33 + ) 34 + const [onMainScroll, isScrolledDown, resetMainScroll] = 35 + useOnMainScroll(store) 36 + 37 + const onSoftReset = React.useCallback(() => { 38 + flatListRef.current?.scrollToOffset({offset: 0}) 39 + resetMainScroll() 40 + }, [flatListRef, resetMainScroll]) 41 + 42 + useFocusEffect( 43 + React.useCallback(() => { 44 + const softResetSub = store.onScreenSoftReset(onSoftReset) 45 + const multifeedCleanup = multifeed.registerListeners() 46 + const cleanup = () => { 47 + softResetSub.remove() 48 + multifeedCleanup() 49 + } 50 + 51 + store.shell.setMinimalShellMode(false) 52 + return cleanup 53 + }, [store, multifeed, onSoftReset]), 54 + ) 55 + 56 + React.useEffect(() => { 57 + if ( 58 + isEqual( 59 + multifeed.feedInfos.map(f => f.uri), 60 + store.me.savedFeeds.all.map(f => f.uri), 61 + ) 62 + ) { 63 + // no changes 64 + return 65 + } 66 + multifeed.refresh() 67 + }, [multifeed, store.me.savedFeeds.all]) 68 + 69 + const onPressCompose = React.useCallback(() => { 70 + store.shell.openComposer({}) 71 + }, [store]) 72 + 73 + const renderHeaderBtn = React.useCallback(() => { 74 + return ( 75 + <Link 76 + href="/settings/saved-feeds" 77 + hitSlop={10} 78 + accessibilityRole="button" 79 + accessibilityLabel="Edit Saved Feeds" 80 + accessibilityHint="Opens screen to edit Saved Feeds"> 81 + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> 82 + </Link> 83 + ) 84 + }, [pal]) 85 + 86 + return ( 87 + <View style={[pal.view, styles.container]}> 88 + <MultiFeed 89 + scrollElRef={flatListRef} 90 + multifeed={multifeed} 91 + onScroll={onMainScroll} 92 + scrollEventThrottle={100} 93 + headerOffset={HEADER_OFFSET} 94 + /> 95 + <ViewHeader 96 + title="My Feeds" 97 + canGoBack={false} 98 + hideOnScroll 99 + renderButton={renderHeaderBtn} 100 + /> 101 + {isScrolledDown ? ( 102 + <LoadLatestBtn 103 + onPress={onSoftReset} 104 + label="Scroll to top" 105 + showIndicator={false} 106 + /> 107 + ) : null} 108 + <FAB 109 + testID="composeFAB" 110 + onPress={onPressCompose} 111 + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 112 + accessibilityRole="button" 113 + accessibilityLabel="Compose post" 114 + accessibilityHint="" 115 + /> 116 + </View> 117 + ) 118 + }), 119 + ) 120 + 121 + const styles = StyleSheet.create({ 122 + container: { 123 + flex: 1, 124 + }, 125 + })
+5 -1
src/view/screens/SavedFeeds.tsx
··· 118 118 pal.border, 119 119 isDesktopWeb && styles.desktopContainer, 120 120 ]}> 121 - <ViewHeader title="My Feeds" showOnDesktop showBorder={!isDesktopWeb} /> 121 + <ViewHeader 122 + title="Edit My Feeds" 123 + showOnDesktop 124 + showBorder={!isDesktopWeb} 125 + /> 122 126 <DraggableFlatList 123 127 containerStyle={[!isDesktopWeb && s.flex1]} 124 128 data={savedFeeds.all}
+19 -11
src/view/shell/Drawer.tsx
··· 30 30 MoonIcon, 31 31 UserIconSolid, 32 32 SatelliteDishIcon, 33 + SatelliteDishIconSolid, 33 34 HandIcon, 34 35 } from 'lib/icons' 35 36 import {UserAvatar} from 'view/com/util/UserAvatar' ··· 50 51 const store = useStores() 51 52 const navigation = useNavigation<NavigationProp>() 52 53 const {track} = useAnalytics() 53 - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = 54 + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = 54 55 useNavigationTabState() 55 56 56 57 const {notifications} = store.me ··· 97 98 onPressTab('MyProfile') 98 99 }, [onPressTab]) 99 100 100 - const onPressMyFeeds = React.useCallback(() => { 101 - track('Menu:ItemClicked', {url: 'MyFeeds'}) 102 - navigation.navigate('SavedFeeds') 103 - store.shell.closeDrawer() 104 - }, [navigation, track, store.shell]) 101 + const onPressMyFeeds = React.useCallback( 102 + () => onPressTab('Feeds'), 103 + [onPressTab], 104 + ) 105 105 106 106 const onPressModeration = React.useCallback(() => { 107 107 track('Menu:ItemClicked', {url: 'Moderation'}) ··· 240 240 /> 241 241 <MenuItem 242 242 icon={ 243 - <SatelliteDishIcon 244 - strokeWidth={1.5} 245 - style={pal.text as FontAwesomeIconStyle} 246 - size={24} 247 - /> 243 + isAtFeeds ? ( 244 + <SatelliteDishIconSolid 245 + strokeWidth={1.5} 246 + style={pal.text as FontAwesomeIconStyle} 247 + size={24} 248 + /> 249 + ) : ( 250 + <SatelliteDishIcon 251 + strokeWidth={1.5} 252 + style={pal.text as FontAwesomeIconStyle} 253 + size={24} 254 + /> 255 + ) 248 256 } 249 257 label="My Feeds" 250 258 accessibilityLabel="My Feeds"
+29 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 18 18 HomeIconSolid, 19 19 MagnifyingGlassIcon2, 20 20 MagnifyingGlassIcon2Solid, 21 + SatelliteDishIcon, 22 + SatelliteDishIconSolid, 21 23 BellIcon, 22 24 BellIconSolid, 23 25 } from 'lib/icons' ··· 33 35 const pal = usePalette('default') 34 36 const safeAreaInsets = useSafeAreaInsets() 35 37 const {track} = useAnalytics() 36 - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = 38 + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = 37 39 useNavigationTabState() 38 40 39 41 const {footerMinimalShellTransform} = useMinimalShellMode() ··· 57 59 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) 58 60 const onPressSearch = React.useCallback( 59 61 () => onPressTab('Search'), 62 + [onPressTab], 63 + ) 64 + const onPressFeeds = React.useCallback( 65 + () => onPressTab('Feeds'), 60 66 [onPressTab], 61 67 ) 62 68 const onPressNotifications = React.useCallback( ··· 118 124 onPress={onPressSearch} 119 125 accessibilityRole="search" 120 126 accessibilityLabel="Search" 127 + accessibilityHint="" 128 + /> 129 + <Btn 130 + testID="bottomBarFeedsBtn" 131 + icon={ 132 + isAtFeeds ? ( 133 + <SatelliteDishIconSolid 134 + size={25} 135 + style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 136 + strokeWidth={1.8} 137 + /> 138 + ) : ( 139 + <SatelliteDishIcon 140 + size={25} 141 + style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 142 + strokeWidth={1.8} 143 + /> 144 + ) 145 + } 146 + onPress={onPressFeeds} 147 + accessibilityRole="tab" 148 + accessibilityLabel="Feeds" 121 149 accessibilityHint="" 122 150 /> 123 151 <Btn
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 207 207 label="Notifications" 208 208 /> 209 209 <NavItem 210 - href="/settings/saved-feeds" 210 + href="/feeds" 211 211 icon={ 212 212 <SatelliteDishIcon 213 213 strokeWidth={1.75}