Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Feed UI update working branch [WIP] (#1420)

* Feeds navigation on right side of desktop (#1403)

* Remove home feed header on desktop

* Add feeds to right sidebar

* Add simple non-moving header to desktop

* Improve loading state of custom feed header

* Remove log

Co-authored-by: Eric Bailey <git@esb.lol>

* Remove dead comment

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Redesign feeds tab (#1439)

* consolidate saved feeds and discover into one screen

* Add hoverStyle behavior to <Link>

* More UI work on SavedFeeds

* Replace satellite icon with a hashtag

* Tune My Feeds mobile ui

* Handle no results in my feeds

* Remove old DiscoverFeeds screen

* Remove multifeed

* Remove DiscoverFeeds from router

* Improve loading placeholders

* Small fixes

* Fix types

* Fix overflow issue on firefox

* Add icons prompting to open feeds

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Merge feed prototype [WIP] (#1398)

* POC WIP for the mergefeed

* Add feed API wrapper and move mergefeed into it

* Show feed source in mergefeed

* Add lodash.random dep

* Improve mergefeed sampling and reliability

* Tune source ui element

* Improve mergefeed edge condition handling

* Remove in-place update of feeds for performance

* Fix link on native

* Fix bad ref

* Improve variety in mergefeed sampling

* Fix types

* Fix rebase error

* Add missing source field (got dropped in merge)

* Update find more link

* Simplify the right hand feeds nav

* Bring back load latest button on desktop & unify impl

* Add 'From' to source

* Add simple headers to desktop home & notifications

* Fix thread view jumping around horizontally

* Add unread indicators to desktop headers

* Add home feed preference for enabling the mergefeed

* Add a preference for showing replies among followed users only (#1448)

* Add a preference for showing replies among followed users only

* Simplify the reply filter UI

* Fix typo

* Simplified custom feed header

* Add soft reset to custom feed screen

* Drop all the in-post translate links except when expanded (#1455)

* Update mobile feed settings links to match desktop

* Fixes to feeds screen loading states

* Bolder active state of feeds tab on mobile web

* Fix dark mode issue

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Ansh <anshnanda10@gmail.com>

authored by

Paul Frazee
Eric Bailey
Paul Frazee
Ansh
and committed by
GitHub
ea885339 3118e3e9

+1884 -1497
+2
package.json
··· 102 102 "lodash.isequal": "^4.5.0", 103 103 "lodash.omit": "^4.5.0", 104 104 "lodash.once": "^4.1.1", 105 + "lodash.random": "^3.2.0", 105 106 "lodash.samplesize": "^4.2.0", 106 107 "lodash.set": "^4.3.2", 107 108 "lodash.shuffle": "^4.2.0", ··· 168 169 "@types/lodash.isequal": "^4.5.6", 169 170 "@types/lodash.omit": "^4.5.7", 170 171 "@types/lodash.once": "^4.1.7", 172 + "@types/lodash.random": "^3.2.7", 171 173 "@types/lodash.samplesize": "^4.2.7", 172 174 "@types/lodash.set": "^4.3.7", 173 175 "@types/lodash.shuffle": "^4.2.7",
-6
src/Navigation.tsx
··· 40 40 import {NotificationsScreen} from './view/screens/Notifications' 41 41 import {ModerationScreen} from './view/screens/Moderation' 42 42 import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' 43 - import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds' 44 43 import {NotFoundScreen} from './view/screens/NotFound' 45 44 import {SettingsScreen} from './view/screens/Settings' 46 45 import {ProfileScreen} from './view/screens/Profile' ··· 112 111 name="ModerationBlockedAccounts" 113 112 component={ModerationBlockedAccounts} 114 113 options={{title: title('Blocked Accounts')}} 115 - /> 116 - <Stack.Screen 117 - name="DiscoverFeeds" 118 - component={DiscoverFeedsScreen} 119 - options={{title: title('Discover Feeds')}} 120 114 /> 121 115 <Stack.Screen 122 116 name="Settings"
+42 -5
src/lib/api/feed-manip.ts
··· 4 4 AppBskyEmbedRecordWithMedia, 5 5 AppBskyEmbedRecord, 6 6 } from '@atproto/api' 7 + import {FeedSourceInfo} from './feed/types' 7 8 import {isPostInLanguage} from '../../locale/helpers' 8 9 type FeedViewPost = AppBskyFeedDefs.FeedViewPost 9 10 ··· 64 65 ) 65 66 } 66 67 68 + get source(): FeedSourceInfo | undefined { 69 + return this.items.find(item => '__source' in item && !!item.__source) 70 + ?.__source as FeedSourceInfo 71 + } 72 + 67 73 containsUri(uri: string) { 68 74 return !!this.items.find(item => item.post.uri === uri) 69 75 } ··· 90 96 this.items.splice(0, 0, {post: reply.parent}) 91 97 } 92 98 } 99 + } 100 + 101 + isFollowingAllAuthors(userDid: string) { 102 + const item = this.rootItem 103 + if (item.post.author.did === userDid) { 104 + return true 105 + } 106 + if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { 107 + const parent = item.reply?.parent 108 + if (parent?.author.did === userDid) { 109 + return true 110 + } 111 + return ( 112 + parent?.author.viewer?.following && item.post.author.viewer?.following 113 + ) 114 + } 115 + return false 93 116 } 94 117 } 95 118 ··· 222 245 return slices 223 246 } 224 247 225 - static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) { 248 + static thresholdRepliesOnly({ 249 + userDid, 250 + minLikes, 251 + followedOnly, 252 + }: { 253 + userDid: string 254 + minLikes: number 255 + followedOnly: boolean 256 + }) { 226 257 return ( 227 258 tuner: FeedTuner, 228 259 slices: FeedViewPostsSlice[], 229 260 ): FeedViewPostsSlice[] => { 230 - // remove any replies without at least repliesThreshold likes 261 + // remove any replies without at least minLikes likes 231 262 for (let i = slices.length - 1; i >= 0; i--) { 232 - if (slices[i].isFullThread || !slices[i].isReply) { 263 + const slice = slices[i] 264 + if (slice.isFullThread || !slice.isReply) { 233 265 continue 234 266 } 235 267 236 - const item = slices[i].rootItem 268 + const item = slice.rootItem 237 269 const isRepost = Boolean(item.reason) 238 - if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) { 270 + if (isRepost) { 271 + continue 272 + } 273 + if ((item.post.likeCount || 0) < minLikes) { 274 + slices.splice(i, 1) 275 + } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { 239 276 slices.splice(i, 1) 240 277 } 241 278 }
+45
src/lib/api/feed/author.ts
··· 1 + import { 2 + AppBskyFeedDefs, 3 + AppBskyFeedGetAuthorFeed as GetAuthorFeed, 4 + } from '@atproto/api' 5 + import {RootStoreModel} from 'state/index' 6 + import {FeedAPI, FeedAPIResponse} from './types' 7 + 8 + export class AuthorFeedAPI implements FeedAPI { 9 + cursor: string | undefined 10 + 11 + constructor( 12 + public rootStore: RootStoreModel, 13 + public params: GetAuthorFeed.QueryParams, 14 + ) {} 15 + 16 + reset() { 17 + this.cursor = undefined 18 + } 19 + 20 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 21 + const res = await this.rootStore.agent.getAuthorFeed({ 22 + ...this.params, 23 + limit: 1, 24 + }) 25 + return res.data.feed[0] 26 + } 27 + 28 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 29 + const res = await this.rootStore.agent.getAuthorFeed({ 30 + ...this.params, 31 + cursor: this.cursor, 32 + limit, 33 + }) 34 + if (res.success) { 35 + this.cursor = res.data.cursor 36 + return { 37 + cursor: res.data.cursor, 38 + feed: res.data.feed, 39 + } 40 + } 41 + return { 42 + feed: [], 43 + } 44 + } 45 + }
+52
src/lib/api/feed/custom.ts
··· 1 + import { 2 + AppBskyFeedDefs, 3 + AppBskyFeedGetFeed as GetCustomFeed, 4 + } from '@atproto/api' 5 + import {RootStoreModel} from 'state/index' 6 + import {FeedAPI, FeedAPIResponse} from './types' 7 + 8 + export class CustomFeedAPI implements FeedAPI { 9 + cursor: string | undefined 10 + 11 + constructor( 12 + public rootStore: RootStoreModel, 13 + public params: GetCustomFeed.QueryParams, 14 + ) {} 15 + 16 + reset() { 17 + this.cursor = undefined 18 + } 19 + 20 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 21 + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 22 + ...this.params, 23 + limit: 1, 24 + }) 25 + return res.data.feed[0] 26 + } 27 + 28 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 29 + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 30 + ...this.params, 31 + cursor: this.cursor, 32 + limit, 33 + }) 34 + if (res.success) { 35 + this.cursor = res.data.cursor 36 + // NOTE 37 + // some custom feeds fail to enforce the pagination limit 38 + // so we manually truncate here 39 + // -prf 40 + if (res.data.feed.length > limit) { 41 + res.data.feed = res.data.feed.slice(0, limit) 42 + } 43 + return { 44 + cursor: res.data.cursor, 45 + feed: res.data.feed, 46 + } 47 + } 48 + return { 49 + feed: [], 50 + } 51 + } 52 + }
+37
src/lib/api/feed/following.ts
··· 1 + import {AppBskyFeedDefs} from '@atproto/api' 2 + import {RootStoreModel} from 'state/index' 3 + import {FeedAPI, FeedAPIResponse} from './types' 4 + 5 + export class FollowingFeedAPI implements FeedAPI { 6 + cursor: string | undefined 7 + 8 + constructor(public rootStore: RootStoreModel) {} 9 + 10 + reset() { 11 + this.cursor = undefined 12 + } 13 + 14 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 15 + const res = await this.rootStore.agent.getTimeline({ 16 + limit: 1, 17 + }) 18 + return res.data.feed[0] 19 + } 20 + 21 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 22 + const res = await this.rootStore.agent.getTimeline({ 23 + cursor: this.cursor, 24 + limit, 25 + }) 26 + if (res.success) { 27 + this.cursor = res.data.cursor 28 + return { 29 + cursor: res.data.cursor, 30 + feed: res.data.feed, 31 + } 32 + } 33 + return { 34 + feed: [], 35 + } 36 + } 37 + }
+45
src/lib/api/feed/likes.ts
··· 1 + import { 2 + AppBskyFeedDefs, 3 + AppBskyFeedGetActorLikes as GetActorLikes, 4 + } from '@atproto/api' 5 + import {RootStoreModel} from 'state/index' 6 + import {FeedAPI, FeedAPIResponse} from './types' 7 + 8 + export class LikesFeedAPI implements FeedAPI { 9 + cursor: string | undefined 10 + 11 + constructor( 12 + public rootStore: RootStoreModel, 13 + public params: GetActorLikes.QueryParams, 14 + ) {} 15 + 16 + reset() { 17 + this.cursor = undefined 18 + } 19 + 20 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 21 + const res = await this.rootStore.agent.getActorLikes({ 22 + ...this.params, 23 + limit: 1, 24 + }) 25 + return res.data.feed[0] 26 + } 27 + 28 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 29 + const res = await this.rootStore.agent.getActorLikes({ 30 + ...this.params, 31 + cursor: this.cursor, 32 + limit, 33 + }) 34 + if (res.success) { 35 + this.cursor = res.data.cursor 36 + return { 37 + cursor: res.data.cursor, 38 + feed: res.data.feed, 39 + } 40 + } 41 + return { 42 + feed: [], 43 + } 44 + } 45 + }
+236
src/lib/api/feed/merge.ts
··· 1 + import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' 2 + import shuffle from 'lodash.shuffle' 3 + import {RootStoreModel} from 'state/index' 4 + import {timeout} from 'lib/async/timeout' 5 + import {bundleAsync} from 'lib/async/bundle' 6 + import {feedUriToHref} from 'lib/strings/url-helpers' 7 + import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' 8 + 9 + const REQUEST_WAIT_MS = 500 // 500ms 10 + const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours 11 + 12 + export class MergeFeedAPI implements FeedAPI { 13 + following: MergeFeedSource_Following 14 + customFeeds: MergeFeedSource_Custom[] = [] 15 + feedCursor = 0 16 + itemCursor = 0 17 + sampleCursor = 0 18 + 19 + constructor(public rootStore: RootStoreModel) { 20 + this.following = new MergeFeedSource_Following(this.rootStore) 21 + } 22 + 23 + reset() { 24 + this.following = new MergeFeedSource_Following(this.rootStore) 25 + this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() 26 + this.feedCursor = 0 27 + this.itemCursor = 0 28 + this.sampleCursor = 0 29 + } 30 + 31 + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 32 + const res = await this.rootStore.agent.getTimeline({ 33 + limit: 1, 34 + }) 35 + return res.data.feed[0] 36 + } 37 + 38 + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 39 + // we capture here to ensure the data has loaded 40 + this._captureFeedsIfNeeded() 41 + 42 + const promises = [] 43 + 44 + // always keep following topped up 45 + if (this.following.numReady < limit) { 46 + promises.push(this.following.fetchNext(30)) 47 + } 48 + 49 + // pick the next feeds to sample from 50 + const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3) 51 + this.feedCursor += 3 52 + if (this.feedCursor > this.customFeeds.length) { 53 + this.feedCursor = 0 54 + } 55 + 56 + // top up the feeds 57 + for (const feed of feeds) { 58 + if (feed.numReady < 5) { 59 + promises.push(feed.fetchNext(10)) 60 + } 61 + } 62 + 63 + // wait for requests (all capped at a fixed timeout) 64 + await Promise.all(promises) 65 + 66 + // assemble a response by sampling from feeds with content 67 + const posts: AppBskyFeedDefs.FeedViewPost[] = [] 68 + while (posts.length < limit) { 69 + let slice = this.sampleItem() 70 + if (slice[0]) { 71 + posts.push(slice[0]) 72 + } else { 73 + break 74 + } 75 + } 76 + 77 + return { 78 + cursor: posts.length ? 'fake' : undefined, 79 + feed: posts, 80 + } 81 + } 82 + 83 + sampleItem() { 84 + const i = this.itemCursor++ 85 + const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) 86 + const canSample = candidateFeeds.length > 0 87 + const hasFollows = this.following.numReady > 0 88 + 89 + // this condition establishes the frequency that custom feeds are woven into follows 90 + const shouldSample = 91 + i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0) 92 + 93 + if (!canSample && !hasFollows) { 94 + // no data available 95 + return [] 96 + } 97 + if (shouldSample || !hasFollows) { 98 + // time to sample, or the user isnt following anybody 99 + return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) 100 + } 101 + // not time to sample 102 + return this.following.take(1) 103 + } 104 + 105 + _captureFeedsIfNeeded() { 106 + if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) { 107 + return 108 + } 109 + if (this.customFeeds.length === 0) { 110 + this.customFeeds = shuffle( 111 + this.rootStore.me.savedFeeds.all.map( 112 + feed => 113 + new MergeFeedSource_Custom( 114 + this.rootStore, 115 + feed.uri, 116 + feed.displayName, 117 + ), 118 + ), 119 + ) 120 + } 121 + } 122 + } 123 + 124 + class MergeFeedSource { 125 + sourceInfo: FeedSourceInfo | undefined 126 + cursor: string | undefined = undefined 127 + queue: AppBskyFeedDefs.FeedViewPost[] = [] 128 + hasMore = true 129 + 130 + constructor(public rootStore: RootStoreModel) {} 131 + 132 + get numReady() { 133 + return this.queue.length 134 + } 135 + 136 + get needsFetch() { 137 + return this.hasMore && this.queue.length === 0 138 + } 139 + 140 + reset() { 141 + this.cursor = undefined 142 + this.queue = [] 143 + this.hasMore = true 144 + } 145 + 146 + take(n: number): AppBskyFeedDefs.FeedViewPost[] { 147 + return this.queue.splice(0, n) 148 + } 149 + 150 + async fetchNext(n: number) { 151 + await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)]) 152 + } 153 + 154 + _fetchNextInner = bundleAsync(async (n: number) => { 155 + const res = await this._getFeed(this.cursor, n) 156 + if (res.success) { 157 + this.cursor = res.data.cursor 158 + if (res.data.feed.length) { 159 + this.queue = this.queue.concat(res.data.feed) 160 + } else { 161 + this.hasMore = false 162 + } 163 + } else { 164 + this.hasMore = false 165 + } 166 + }) 167 + 168 + protected _getFeed( 169 + _cursor: string | undefined, 170 + _limit: number, 171 + ): Promise<AppBskyFeedGetTimeline.Response> { 172 + throw new Error('Must be overridden') 173 + } 174 + } 175 + 176 + class MergeFeedSource_Following extends MergeFeedSource { 177 + async fetchNext(n: number) { 178 + return this._fetchNextInner(n) 179 + } 180 + 181 + protected async _getFeed( 182 + cursor: string | undefined, 183 + limit: number, 184 + ): Promise<AppBskyFeedGetTimeline.Response> { 185 + const res = await this.rootStore.agent.getTimeline({cursor, limit}) 186 + // filter out mutes pre-emptively to ensure better mixing 187 + res.data.feed = res.data.feed.filter( 188 + post => !post.post.author.viewer?.muted, 189 + ) 190 + return res 191 + } 192 + } 193 + 194 + class MergeFeedSource_Custom extends MergeFeedSource { 195 + minDate: Date 196 + 197 + constructor( 198 + public rootStore: RootStoreModel, 199 + public feedUri: string, 200 + public feedDisplayName: string, 201 + ) { 202 + super(rootStore) 203 + this.sourceInfo = { 204 + displayName: feedDisplayName, 205 + uri: feedUriToHref(feedUri), 206 + } 207 + this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) 208 + } 209 + 210 + protected async _getFeed( 211 + cursor: string | undefined, 212 + limit: number, 213 + ): Promise<AppBskyFeedGetTimeline.Response> { 214 + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 215 + cursor, 216 + limit, 217 + feed: this.feedUri, 218 + }) 219 + // NOTE 220 + // some custom feeds fail to enforce the pagination limit 221 + // so we manually truncate here 222 + // -prf 223 + if (limit && res.data.feed.length > limit) { 224 + res.data.feed = res.data.feed.slice(0, limit) 225 + } 226 + // filter out older posts 227 + res.data.feed = res.data.feed.filter( 228 + post => new Date(post.post.indexedAt) > this.minDate, 229 + ) 230 + // attach source info 231 + for (const post of res.data.feed) { 232 + post.__source = this.sourceInfo 233 + } 234 + return res 235 + } 236 + }
+17
src/lib/api/feed/types.ts
··· 1 + import {AppBskyFeedDefs} from '@atproto/api' 2 + 3 + export interface FeedAPIResponse { 4 + cursor?: string 5 + feed: AppBskyFeedDefs.FeedViewPost[] 6 + } 7 + 8 + export interface FeedAPI { 9 + reset(): void 10 + peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> 11 + fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> 12 + } 13 + 14 + export interface FeedSourceInfo { 15 + uri: string 16 + displayName: string 17 + }
+8 -56
src/lib/icons.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, TextStyle, ViewStyle} from 'react-native' 3 - import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg' 3 + import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg' 4 4 5 5 export function GridIcon({ 6 6 style, ··· 884 884 ) 885 885 } 886 886 887 - export function SatelliteDishIconSolid({ 888 - style, 889 - size, 890 - strokeWidth = 1.5, 891 - }: { 892 - style?: StyleProp<ViewStyle> 893 - size?: string | number 894 - strokeWidth?: number 895 - }) { 896 - return ( 897 - <Svg 898 - width={size || 24} 899 - height={size || 24} 900 - viewBox="0 0 22 22" 901 - style={style} 902 - fill="none" 903 - stroke="none"> 904 - <Path 905 - d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z" 906 - fill="currentColor" 907 - /> 908 - <Path 909 - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" 910 - stroke="currentColor" 911 - strokeWidth={strokeWidth} 912 - strokeLinecap="round" 913 - /> 914 - <Path 915 - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" 916 - stroke="currentColor" 917 - strokeWidth={strokeWidth} 918 - strokeLinecap="round" 919 - /> 920 - <Circle cx="10" cy="12" r="2" fill="currentColor" /> 921 - </Svg> 922 - ) 923 - } 924 - 925 - export function SatelliteDishIcon({ 887 + export function HashtagIcon({ 926 888 style, 927 889 size, 928 890 strokeWidth = 1.5, ··· 934 896 return ( 935 897 <Svg 936 898 fill="none" 937 - viewBox="0 0 22 22" 938 - strokeWidth={strokeWidth} 939 899 stroke="currentColor" 900 + viewBox="0 0 30 30" 901 + strokeWidth={strokeWidth} 940 902 width={size} 941 903 height={size} 942 904 style={style}> 943 - <Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" /> 944 - <Path 945 - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" 946 - strokeLinecap="round" 947 - /> 948 - <Path 949 - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" 950 - strokeLinecap="round" 951 - /> 952 - <Path 953 - d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z" 954 - fill="currentColor" 955 - stroke="none" 956 - /> 905 + <Path d="M2 10H28" strokeLinecap="round" /> 906 + <Path d="M2 20H28" strokeLinecap="round" /> 907 + <Path d="M11 3L9 27" strokeLinecap="round" /> 908 + <Path d="M21 3L19 27" strokeLinecap="round" /> 957 909 </Svg> 958 910 ) 959 911 }
-1
src/lib/routes/types.ts
··· 9 9 ModerationMuteLists: undefined 10 10 ModerationMutedAccounts: undefined 11 11 ModerationBlockedAccounts: undefined 12 - DiscoverFeeds: undefined 13 12 Settings: undefined 14 13 Profile: {name: string; hideBackButton?: boolean} 15 14 ProfileFollowers: {name: string}
+9
src/lib/strings/url-helpers.ts
··· 129 129 } 130 130 } 131 131 132 + export function feedUriToHref(url: string): string { 133 + try { 134 + const {hostname, rkey} = new AtUri(url) 135 + return `/profile/${hostname}/feed/${rkey}` 136 + } catch { 137 + return '' 138 + } 139 + } 140 + 132 141 export function getYoutubeVideoId(link: string): string | undefined { 133 142 let url 134 143 try {
-1
src/routes.ts
··· 4 4 Home: '/', 5 5 Search: '/search', 6 6 Feeds: '/feeds', 7 - DiscoverFeeds: '/search/feeds', 8 7 Notifications: '/notifications', 9 8 Settings: '/settings', 10 9 Moderation: '/moderation',
-227
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 './posts-slice' 8 - import {makeProfileLink} from 'lib/routes/links' 9 - 10 - const FEED_PAGE_SIZE = 10 11 - const FEEDS_PAGE_SIZE = 3 12 - 13 - export type MultiFeedItem = 14 - | { 15 - _reactKey: string 16 - type: 'header' 17 - } 18 - | { 19 - _reactKey: string 20 - type: 'feed-header' 21 - avatar: string | undefined 22 - title: string 23 - } 24 - | { 25 - _reactKey: string 26 - type: 'feed-slice' 27 - slice: PostsFeedSliceModel 28 - } 29 - | { 30 - _reactKey: string 31 - type: 'feed-loading' 32 - } 33 - | { 34 - _reactKey: string 35 - type: 'feed-error' 36 - error: string 37 - } 38 - | { 39 - _reactKey: string 40 - type: 'feed-footer' 41 - title: string 42 - uri: string 43 - } 44 - | { 45 - _reactKey: string 46 - type: 'footer' 47 - } 48 - 49 - export class PostsMultiFeedModel { 50 - // state 51 - isLoading = false 52 - isRefreshing = false 53 - hasLoaded = false 54 - hasMore = true 55 - 56 - // data 57 - feedInfos: CustomFeedModel[] = [] 58 - feeds: PostsFeedModel[] = [] 59 - 60 - constructor(public rootStore: RootStoreModel) { 61 - makeAutoObservable(this, {rootStore: false}, {autoBind: true}) 62 - } 63 - 64 - get hasContent() { 65 - return this.feeds.length !== 0 66 - } 67 - 68 - get isEmpty() { 69 - return this.hasLoaded && !this.hasContent 70 - } 71 - 72 - get items() { 73 - const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] 74 - for (let i = 0; i < this.feedInfos.length; i++) { 75 - if (!this.feeds[i]) { 76 - break 77 - } 78 - const feed = this.feeds[i] 79 - const feedInfo = this.feedInfos[i] 80 - const urip = new AtUri(feedInfo.uri) 81 - items.push({ 82 - _reactKey: `__feed_header_${i}__`, 83 - type: 'feed-header', 84 - avatar: feedInfo.data.avatar, 85 - title: feedInfo.displayName, 86 - }) 87 - if (feed.isLoading) { 88 - items.push({ 89 - _reactKey: `__feed_loading_${i}__`, 90 - type: 'feed-loading', 91 - }) 92 - } else if (feed.hasError) { 93 - items.push({ 94 - _reactKey: `__feed_error_${i}__`, 95 - type: 'feed-error', 96 - error: feed.error, 97 - }) 98 - } else { 99 - for (let j = 0; j < feed.slices.length; j++) { 100 - items.push({ 101 - _reactKey: `__feed_slice_${i}_${j}__`, 102 - type: 'feed-slice', 103 - slice: feed.slices[j], 104 - }) 105 - } 106 - } 107 - items.push({ 108 - _reactKey: `__feed_footer_${i}__`, 109 - type: 'feed-footer', 110 - title: feedInfo.displayName, 111 - uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey), 112 - }) 113 - } 114 - if (!this.hasMore && this.hasContent) { 115 - // only show if hasContent to avoid double discover-feed links 116 - items.push({_reactKey: '__footer__', type: 'footer'}) 117 - } 118 - return items 119 - } 120 - 121 - // public api 122 - // = 123 - 124 - /** 125 - * Nuke all data 126 - */ 127 - clear() { 128 - this.rootStore.log.debug('MultiFeedModel:clear') 129 - this.isLoading = false 130 - this.isRefreshing = false 131 - this.hasLoaded = false 132 - this.hasMore = true 133 - this.feeds = [] 134 - } 135 - 136 - /** 137 - * Register any event listeners. Returns a cleanup function. 138 - */ 139 - registerListeners() { 140 - const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) 141 - return () => sub.remove() 142 - } 143 - 144 - /** 145 - * Reset and load 146 - */ 147 - async refresh() { 148 - this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds 149 - await this.loadMore(true) 150 - } 151 - 152 - /** 153 - * Load latest in the active feeds 154 - */ 155 - loadLatest() { 156 - for (const feed of this.feeds) { 157 - /* dont await */ feed.refresh() 158 - } 159 - } 160 - 161 - /** 162 - * Load more posts to the end of the feed 163 - */ 164 - loadMore = bundleAsync(async (isRefreshing: boolean = false) => { 165 - if (!isRefreshing && !this.hasMore) { 166 - return 167 - } 168 - if (isRefreshing) { 169 - this.isRefreshing = true // set optimistically for UI 170 - this.feeds = [] 171 - } 172 - this._xLoading(isRefreshing) 173 - const start = this.feeds.length 174 - const newFeeds: PostsFeedModel[] = [] 175 - for ( 176 - let i = start; 177 - i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; 178 - i++ 179 - ) { 180 - const feed = new PostsFeedModel(this.rootStore, 'custom', { 181 - feed: this.feedInfos[i].uri, 182 - }) 183 - feed.pageSize = FEED_PAGE_SIZE 184 - await feed.setup() 185 - newFeeds.push(feed) 186 - } 187 - runInAction(() => { 188 - this.feeds = this.feeds.concat(newFeeds) 189 - this.hasMore = this.feeds.length < this.feedInfos.length 190 - }) 191 - this._xIdle() 192 - }) 193 - 194 - /** 195 - * Attempt to load more again after a failure 196 - */ 197 - async retryLoadMore() { 198 - this.hasMore = true 199 - return this.loadMore() 200 - } 201 - 202 - /** 203 - * Removes posts from the feed upon deletion. 204 - */ 205 - onPostDeleted(uri: string) { 206 - for (const f of this.feeds) { 207 - f.onPostDeleted(uri) 208 - } 209 - } 210 - 211 - // state transitions 212 - // = 213 - 214 - _xLoading(isRefreshing = false) { 215 - this.isLoading = true 216 - this.isRefreshing = isRefreshing 217 - } 218 - 219 - _xIdle() { 220 - this.isLoading = false 221 - this.isRefreshing = false 222 - this.hasLoaded = true 223 - } 224 - 225 - // helper functions 226 - // = 227 - }
+3
src/state/models/feeds/posts-slice.ts
··· 2 2 import {RootStoreModel} from '../root-store' 3 3 import {FeedViewPostsSlice} from 'lib/api/feed-manip' 4 4 import {PostsFeedItemModel} from './post' 5 + import {FeedSourceInfo} from 'lib/api/feed/types' 5 6 6 7 export class PostsFeedSliceModel { 7 8 // ui state ··· 9 10 10 11 // data 11 12 items: PostsFeedItemModel[] = [] 13 + source: FeedSourceInfo | undefined 12 14 13 15 constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { 14 16 this._reactKey = slice._reactKey 17 + this.source = slice.source 15 18 for (let i = 0; i < slice.items.length; i++) { 16 19 this.items.push( 17 20 new PostsFeedItemModel(
+57 -115
src/state/models/feeds/posts.ts
··· 14 14 import {track} from 'lib/analytics/analytics' 15 15 import {FeedViewPostsSlice} from 'lib/api/feed-manip' 16 16 17 + import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types' 18 + import {FollowingFeedAPI} from 'lib/api/feed/following' 19 + import {AuthorFeedAPI} from 'lib/api/feed/author' 20 + import {LikesFeedAPI} from 'lib/api/feed/likes' 21 + import {CustomFeedAPI} from 'lib/api/feed/custom' 22 + import {MergeFeedAPI} from 'lib/api/feed/merge' 23 + 17 24 const PAGE_SIZE = 30 18 25 19 26 type Options = { ··· 27 34 type QueryParams = 28 35 | GetTimeline.QueryParams 29 36 | GetAuthorFeed.QueryParams 37 + | GetActorLikes.QueryParams 30 38 | GetCustomFeed.QueryParams 31 39 32 40 export class PostsFeedModel { ··· 41 49 loadMoreError = '' 42 50 params: QueryParams 43 51 hasMore = true 44 - loadMoreCursor: string | undefined 45 52 pollCursor: string | undefined 53 + api: FeedAPI 46 54 tuner = new FeedTuner() 47 55 pageSize = PAGE_SIZE 48 56 options: Options = {} ··· 50 58 // used to linearize async modifications to state 51 59 lock = new AwaitLock() 52 60 53 - // used to track if what's hot is coming up empty 61 + // used to track if a feed is coming up empty 54 62 emptyFetches = 0 55 63 56 64 // data ··· 58 66 59 67 constructor( 60 68 public rootStore: RootStoreModel, 61 - public feedType: 'home' | 'author' | 'custom' | 'likes', 69 + public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', 62 70 params: QueryParams, 63 71 options?: Options, 64 72 ) { ··· 67 75 { 68 76 rootStore: false, 69 77 params: false, 70 - loadMoreCursor: false, 71 78 }, 72 79 {autoBind: true}, 73 80 ) 74 81 this.params = params 75 82 this.options = options || {} 83 + if (feedType === 'home') { 84 + this.api = new MergeFeedAPI(rootStore) 85 + } else if (feedType === 'following') { 86 + this.api = new FollowingFeedAPI(rootStore) 87 + } else if (feedType === 'author') { 88 + this.api = new AuthorFeedAPI( 89 + rootStore, 90 + params as GetAuthorFeed.QueryParams, 91 + ) 92 + } else if (feedType === 'likes') { 93 + this.api = new LikesFeedAPI( 94 + rootStore, 95 + params as GetActorLikes.QueryParams, 96 + ) 97 + } else if (feedType === 'custom') { 98 + this.api = new CustomFeedAPI( 99 + rootStore, 100 + params as GetCustomFeed.QueryParams, 101 + ) 102 + } else { 103 + this.api = new FollowingFeedAPI(rootStore) 104 + } 76 105 } 77 106 78 107 get hasContent() { ··· 105 134 this.hasLoaded = false 106 135 this.error = '' 107 136 this.hasMore = true 108 - this.loadMoreCursor = undefined 109 137 this.pollCursor = undefined 110 138 this.slices = [] 111 139 this.tuner.reset() ··· 113 141 114 142 get feedTuners() { 115 143 const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled 144 + const areRepliesByFollowedOnlyEnabled = 145 + this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled 116 146 const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold 117 147 const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled 118 148 const areQuotePostsEnabled = ··· 126 156 ), 127 157 ] 128 158 } 129 - if (this.feedType === 'home') { 159 + if (this.feedType === 'home' || this.feedType === 'following') { 130 160 const feedTuners = [] 131 161 132 162 if (areRepostsEnabled) { ··· 136 166 } 137 167 138 168 if (areRepliesEnabled) { 139 - feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold})) 169 + feedTuners.push( 170 + FeedTuner.thresholdRepliesOnly({ 171 + userDid: this.rootStore.session.data?.did || '', 172 + minLikes: repliesThreshold, 173 + followedOnly: areRepliesByFollowedOnlyEnabled, 174 + }), 175 + ) 140 176 } else { 141 177 feedTuners.push(FeedTuner.removeReplies) 142 178 } ··· 161 197 await this.lock.acquireAsync() 162 198 try { 163 199 this.setHasNewLatest(false) 200 + this.api.reset() 164 201 this.tuner.reset() 165 202 this._xLoading(isRefreshing) 166 203 try { 167 - const res = await this._getFeed({limit: this.pageSize}) 204 + const res = await this.api.fetchNext({limit: this.pageSize}) 168 205 await this._replaceAll(res) 169 206 this._xIdle() 170 207 } catch (e: any) { ··· 201 238 } 202 239 this._xLoading() 203 240 try { 204 - const res = await this._getFeed({ 205 - cursor: this.loadMoreCursor, 241 + const res = await this.api.fetchNext({ 206 242 limit: this.pageSize, 207 243 }) 208 244 await this._appendAll(res) ··· 231 267 } 232 268 233 269 /** 234 - * Update content in-place 235 - */ 236 - update = bundleAsync(async () => { 237 - await this.lock.acquireAsync() 238 - try { 239 - if (!this.slices.length) { 240 - return 241 - } 242 - this._xLoading() 243 - let numToFetch = this.slices.length 244 - let cursor 245 - try { 246 - do { 247 - const res: GetTimeline.Response = await this._getFeed({ 248 - cursor, 249 - limit: Math.min(numToFetch, 100), 250 - }) 251 - if (res.data.feed.length === 0) { 252 - break // sanity check 253 - } 254 - this._updateAll(res) 255 - numToFetch -= res.data.feed.length 256 - cursor = res.data.cursor 257 - } while (cursor && numToFetch > 0) 258 - this._xIdle() 259 - } catch (e: any) { 260 - this._xIdle() // don't bubble the error to the user 261 - this.rootStore.log.error('FeedView: Failed to update', { 262 - params: this.params, 263 - e, 264 - }) 265 - } 266 - } finally { 267 - this.lock.release() 268 - } 269 - }) 270 - 271 - /** 272 270 * Check if new posts are available 273 271 */ 274 272 async checkForLatest() { 275 273 if (!this.hasLoaded || this.hasNewLatest || this.isLoading) { 276 274 return 277 275 } 278 - const res = await this._getFeed({limit: 1}) 279 - if (res.data.feed[0]) { 280 - const slices = this.tuner.tune(res.data.feed, this.feedTuners, { 276 + const post = await this.api.peekLatest() 277 + if (post) { 278 + const slices = this.tuner.tune([post], this.feedTuners, { 281 279 dryRun: true, 282 280 }) 283 281 if (slices[0]) { ··· 345 343 // helper functions 346 344 // = 347 345 348 - async _replaceAll( 349 - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, 350 - ) { 351 - this.pollCursor = res.data.feed[0]?.post.uri 346 + async _replaceAll(res: FeedAPIResponse) { 347 + this.pollCursor = res.feed[0]?.post.uri 352 348 return this._appendAll(res, true) 353 349 } 354 350 355 - async _appendAll( 356 - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, 357 - replace = false, 358 - ) { 359 - this.loadMoreCursor = res.data.cursor 360 - this.hasMore = !!this.loadMoreCursor 351 + async _appendAll(res: FeedAPIResponse, replace = false) { 352 + this.hasMore = !!res.cursor 361 353 if (replace) { 362 354 this.emptyFetches = 0 363 355 } 364 356 365 357 this.rootStore.me.follows.hydrateProfiles( 366 - res.data.feed.map(item => item.post.author), 358 + res.feed.map(item => item.post.author), 367 359 ) 368 - for (const item of res.data.feed) { 360 + for (const item of res.feed) { 369 361 this.rootStore.posts.fromFeedItem(item) 370 362 } 371 363 372 364 const slices = this.options.isSimpleFeed 373 - ? res.data.feed.map(item => new FeedViewPostsSlice([item])) 374 - : this.tuner.tune(res.data.feed, this.feedTuners) 365 + ? res.feed.map(item => new FeedViewPostsSlice([item])) 366 + : this.tuner.tune(res.feed, this.feedTuners) 375 367 376 368 const toAppend: PostsFeedSliceModel[] = [] 377 369 for (const slice of slices) { ··· 400 392 } 401 393 } 402 394 }) 403 - } 404 - 405 - _updateAll( 406 - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, 407 - ) { 408 - for (const item of res.data.feed) { 409 - this.rootStore.posts.fromFeedItem(item) 410 - const existingSlice = this.slices.find(slice => 411 - slice.containsUri(item.post.uri), 412 - ) 413 - if (existingSlice) { 414 - const existingItem = existingSlice.items.find( 415 - item2 => item2.post.uri === item.post.uri, 416 - ) 417 - if (existingItem) { 418 - existingItem.copyMetrics(item) 419 - } 420 - } 421 - } 422 - } 423 - 424 - protected async _getFeed( 425 - params: QueryParams, 426 - ): Promise< 427 - GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response 428 - > { 429 - params = Object.assign({}, this.params, params) 430 - if (this.feedType === 'home') { 431 - return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) 432 - } else if (this.feedType === 'custom') { 433 - const res = await this.rootStore.agent.app.bsky.feed.getFeed( 434 - params as GetCustomFeed.QueryParams, 435 - ) 436 - // NOTE 437 - // some custom feeds fail to enforce the pagination limit 438 - // so we manually truncate here 439 - // -prf 440 - if (params.limit && res.data.feed.length > params.limit) { 441 - res.data.feed = res.data.feed.slice(0, params.limit) 442 - } 443 - return res 444 - } else if (this.feedType === 'author') { 445 - return this.rootStore.agent.getAuthorFeed( 446 - params as GetAuthorFeed.QueryParams, 447 - ) 448 - } else { 449 - return this.rootStore.agent.getActorLikes( 450 - params as GetActorLikes.QueryParams, 451 - ) 452 - } 453 395 } 454 396 }
+1 -1
src/state/models/root-store.ts
··· 139 139 this.agent = agent 140 140 applyDebugHeader(this.agent) 141 141 this.me.clear() 142 - /* dont await */ this.preferences.sync() 142 + await this.preferences.sync() 143 143 await this.me.load() 144 144 if (!hadSession) { 145 145 await resetNavigation()
+157
src/state/models/ui/my-feeds.ts
··· 1 + import {makeAutoObservable} from 'mobx' 2 + import {FeedsDiscoveryModel} from '../discovery/feeds' 3 + import {CustomFeedModel} from '../feeds/custom-feed' 4 + import {RootStoreModel} from '../root-store' 5 + 6 + export type MyFeedsItem = 7 + | { 8 + _reactKey: string 9 + type: 'spinner' 10 + } 11 + | { 12 + _reactKey: string 13 + type: 'discover-feeds-loading' 14 + } 15 + | { 16 + _reactKey: string 17 + type: 'error' 18 + error: string 19 + } 20 + | { 21 + _reactKey: string 22 + type: 'saved-feeds-header' 23 + } 24 + | { 25 + _reactKey: string 26 + type: 'saved-feed' 27 + feed: CustomFeedModel 28 + } 29 + | { 30 + _reactKey: string 31 + type: 'saved-feeds-load-more' 32 + } 33 + | { 34 + _reactKey: string 35 + type: 'discover-feeds-header' 36 + } 37 + | { 38 + _reactKey: string 39 + type: 'discover-feeds-no-results' 40 + } 41 + | { 42 + _reactKey: string 43 + type: 'discover-feed' 44 + feed: CustomFeedModel 45 + } 46 + 47 + export class MyFeedsUIModel { 48 + discovery: FeedsDiscoveryModel 49 + 50 + constructor(public rootStore: RootStoreModel) { 51 + makeAutoObservable(this) 52 + this.discovery = new FeedsDiscoveryModel(this.rootStore) 53 + } 54 + 55 + get saved() { 56 + return this.rootStore.me.savedFeeds 57 + } 58 + 59 + get isRefreshing() { 60 + return !this.saved.isLoading && this.saved.isRefreshing 61 + } 62 + 63 + get isLoading() { 64 + return this.saved.isLoading || this.discovery.isLoading 65 + } 66 + 67 + async setup() { 68 + if (!this.saved.hasLoaded) { 69 + await this.saved.refresh() 70 + } 71 + if (!this.discovery.hasLoaded) { 72 + await this.discovery.refresh() 73 + } 74 + } 75 + 76 + async refresh() { 77 + return Promise.all([this.saved.refresh(), this.discovery.refresh()]) 78 + } 79 + 80 + async loadMore() { 81 + return this.discovery.loadMore() 82 + } 83 + 84 + get items() { 85 + let items: MyFeedsItem[] = [] 86 + 87 + items.push({ 88 + _reactKey: '__saved_feeds_header__', 89 + type: 'saved-feeds-header', 90 + }) 91 + if (this.saved.isLoading) { 92 + items.push({ 93 + _reactKey: '__saved_feeds_loading__', 94 + type: 'spinner', 95 + }) 96 + } else if (this.saved.hasError) { 97 + items.push({ 98 + _reactKey: '__saved_feeds_error__', 99 + type: 'error', 100 + error: this.saved.error, 101 + }) 102 + } else { 103 + const savedSorted = this.saved.all 104 + .slice() 105 + .sort((a, b) => a.displayName.localeCompare(b.displayName)) 106 + items = items.concat( 107 + savedSorted.map(feed => ({ 108 + _reactKey: `saved-${feed.uri}`, 109 + type: 'saved-feed', 110 + feed, 111 + })), 112 + ) 113 + items.push({ 114 + _reactKey: '__saved_feeds_load_more__', 115 + type: 'saved-feeds-load-more', 116 + }) 117 + } 118 + 119 + items.push({ 120 + _reactKey: '__discover_feeds_header__', 121 + type: 'discover-feeds-header', 122 + }) 123 + if (this.discovery.isLoading && !this.discovery.hasContent) { 124 + items.push({ 125 + _reactKey: '__discover_feeds_loading__', 126 + type: 'discover-feeds-loading', 127 + }) 128 + } else if (this.discovery.hasError) { 129 + items.push({ 130 + _reactKey: '__discover_feeds_error__', 131 + type: 'error', 132 + error: this.discovery.error, 133 + }) 134 + } else if (this.discovery.isEmpty) { 135 + items.push({ 136 + _reactKey: '__discover_feeds_no_results__', 137 + type: 'discover-feeds-no-results', 138 + }) 139 + } else { 140 + items = items.concat( 141 + this.discovery.feeds.map(feed => ({ 142 + _reactKey: `discover-${feed.uri}`, 143 + type: 'discover-feed', 144 + feed, 145 + })), 146 + ) 147 + if (this.discovery.isLoading) { 148 + items.push({ 149 + _reactKey: '__discover_feeds_loading_more__', 150 + type: 'spinner', 151 + }) 152 + } 153 + } 154 + 155 + return items 156 + } 157 + }
+30 -1
src/state/models/ui/preferences.ts
··· 50 50 pinnedFeeds: string[] = [] 51 51 birthDate: Date | undefined = undefined 52 52 homeFeedRepliesEnabled: boolean = true 53 - homeFeedRepliesThreshold: number = 2 53 + homeFeedRepliesByFollowedOnlyEnabled: boolean = true 54 + homeFeedRepliesThreshold: number = 0 54 55 homeFeedRepostsEnabled: boolean = true 55 56 homeFeedQuotePostsEnabled: boolean = true 57 + homeFeedMergeFeedEnabled: boolean = false 56 58 requireAltTextEnabled: boolean = false 57 59 58 60 // used to linearize async modifications to state ··· 78 80 savedFeeds: this.savedFeeds, 79 81 pinnedFeeds: this.pinnedFeeds, 80 82 homeFeedRepliesEnabled: this.homeFeedRepliesEnabled, 83 + homeFeedRepliesByFollowedOnlyEnabled: 84 + this.homeFeedRepliesByFollowedOnlyEnabled, 81 85 homeFeedRepliesThreshold: this.homeFeedRepliesThreshold, 82 86 homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, 83 87 homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, 88 + homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, 84 89 requireAltTextEnabled: this.requireAltTextEnabled, 85 90 } 86 91 } ··· 148 153 ) { 149 154 this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled 150 155 } 156 + // check if home feed replies "followed only" are enabled in preferences, then hydrate 157 + if ( 158 + hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') && 159 + typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean' 160 + ) { 161 + this.homeFeedRepliesByFollowedOnlyEnabled = 162 + v.homeFeedRepliesByFollowedOnlyEnabled 163 + } 151 164 // check if home feed replies threshold is enabled in preferences, then hydrate 152 165 if ( 153 166 hasProp(v, 'homeFeedRepliesThreshold') && ··· 168 181 typeof v.homeFeedQuotePostsEnabled === 'boolean' 169 182 ) { 170 183 this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled 184 + } 185 + // check if home feed mergefeed is enabled in preferences, then hydrate 186 + if ( 187 + hasProp(v, 'homeFeedMergeFeedEnabled') && 188 + typeof v.homeFeedMergeFeedEnabled === 'boolean' 189 + ) { 190 + this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled 171 191 } 172 192 // check if requiring alt text is enabled in preferences, then hydrate 173 193 if ( ··· 449 469 this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled 450 470 } 451 471 472 + toggleHomeFeedRepliesByFollowedOnlyEnabled() { 473 + this.homeFeedRepliesByFollowedOnlyEnabled = 474 + !this.homeFeedRepliesByFollowedOnlyEnabled 475 + } 476 + 452 477 setHomeFeedRepliesThreshold(threshold: number) { 453 478 this.homeFeedRepliesThreshold = threshold 454 479 } ··· 459 484 460 485 toggleHomeFeedQuotePostsEnabled() { 461 486 this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled 487 + } 488 + 489 + toggleHomeFeedMergeFeedEnabled() { 490 + this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled 462 491 } 463 492 464 493 toggleRequireAltTextEnabled() {
-7
src/state/models/ui/profile.ts
··· 240 240 .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) 241 241 } 242 242 243 - async update() { 244 - const view = this.currentView 245 - if (view instanceof PostsFeedModel) { 246 - await view.update() 247 - } 248 - } 249 - 250 243 async refresh() { 251 244 await Promise.all([this.profile.refresh(), this.currentView.refresh()]) 252 245 }
+5
src/view/com/notifications/Feed.tsx
··· 21 21 scrollElRef, 22 22 onPressTryAgain, 23 23 onScroll, 24 + ListHeaderComponent, 24 25 }: { 25 26 view: NotificationsFeedModel 26 27 scrollElRef?: MutableRefObject<FlatList<any> | null> 27 28 onPressTryAgain?: () => void 28 29 onScroll?: OnScrollCb 30 + ListHeaderComponent?: () => JSX.Element 29 31 }) { 30 32 const pal = usePalette('default') 31 33 const [isPTRing, setIsPTRing] = React.useState(false) ··· 142 144 data={data} 143 145 keyExtractor={item => item._reactKey} 144 146 renderItem={renderItem} 147 + ListHeaderComponent={ListHeaderComponent} 145 148 ListFooterComponent={FeedFooter} 146 149 refreshControl={ 147 150 <RefreshControl ··· 156 159 onScroll={onScroll} 157 160 scrollEventThrottle={100} 158 161 contentContainerStyle={s.contentContainer} 162 + // @ts-ignore our .web version only -prf 163 + desktopFixedHeight 159 164 /> 160 165 ) : null} 161 166 </View>
+5 -3
src/view/com/pager/FeedsTabBar.web.tsx
··· 12 12 export const FeedsTabBar = observer(function FeedsTabBarImpl( 13 13 props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 14 14 ) { 15 - const {isMobile} = useWebMediaQueries() 15 + const {isMobile, isTablet} = useWebMediaQueries() 16 16 if (isMobile) { 17 17 return <FeedsTabBarMobile {...props} /> 18 + } else if (isTablet) { 19 + return <FeedsTabBarTablet {...props} /> 18 20 } else { 19 - return <FeedsTabBarDesktop {...props} /> 21 + return null 20 22 } 21 23 }) 22 24 23 - const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl( 25 + const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( 24 26 props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 25 27 ) { 26 28 const store = useStores()
+8 -5
src/view/com/pager/FeedsTabBarMobile.tsx
··· 9 9 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 10 10 import {Link} from '../util/Link' 11 11 import {Text} from '../util/text/Text' 12 - import {CogIcon} from 'lib/icons' 13 12 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 + import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 14 14 import {s} from 'lib/styles' 15 15 import {HITSLOP_10} from 'lib/constants' 16 16 ··· 67 67 </Text> 68 68 <View style={[pal.view]}> 69 69 <Link 70 - href="/settings/saved-feeds" 70 + href="/settings/home-feed" 71 71 hitSlop={HITSLOP_10} 72 72 accessibilityRole="button" 73 - accessibilityLabel="Edit Saved Feeds" 74 - accessibilityHint="Opens screen to edit Saved Feeds"> 75 - <CogIcon size={21} strokeWidth={2} style={pal.textLight} /> 73 + accessibilityLabel="Home Feed Preferences" 74 + accessibilityHint=""> 75 + <FontAwesomeIcon 76 + icon="sliders" 77 + style={pal.textLight as FontAwesomeIconStyle} 78 + /> 76 79 </Link> 77 80 </View> 78 81 </View>
+2
src/view/com/post-thread/PostThread.tsx
··· 357 357 } 358 358 onScrollToIndexFailed={onScrollToIndexFailed} 359 359 style={s.hContentRegion} 360 + // @ts-ignore our .web version only -prf 361 + desktopFixedHeight 360 362 /> 361 363 ) 362 364 })
-9
src/view/com/post-thread/PostThreadItem.tsx
··· 483 483 /> 484 484 </ContentHider> 485 485 )} 486 - {needsTranslation && ( 487 - <View style={[pal.borderDark, styles.translateLink]}> 488 - <Link href={translatorUrl} title="Translate"> 489 - <Text type="sm" style={pal.link}> 490 - Translate this post 491 - </Text> 492 - </Link> 493 - </View> 494 - )} 495 486 <PostCtrls 496 487 itemUri={itemUri} 497 488 itemCid={itemCid}
+2 -20
src/view/com/post/Post.tsx
··· 1 - import React, {useState, useMemo} from 'react' 1 + import React, {useState} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 Linking, ··· 28 28 import {useStores} from 'state/index' 29 29 import {s, colors} from 'lib/styles' 30 30 import {usePalette} from 'lib/hooks/usePalette' 31 - import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 31 + import {getTranslatorLink} from '../../../locale/helpers' 32 32 import {makeProfileLink} from 'lib/routes/links' 33 33 34 34 export const Post = observer(function PostImpl({ ··· 116 116 } 117 117 118 118 const translatorUrl = getTranslatorLink(record?.text || '') 119 - const needsTranslation = useMemo( 120 - () => 121 - store.preferences.contentLanguages.length > 0 && 122 - !isPostInLanguage(item.post, store.preferences.contentLanguages), 123 - [item.post, store.preferences.contentLanguages], 124 - ) 125 119 126 120 const onPressReply = React.useCallback(() => { 127 121 store.shell.openComposer({ ··· 256 250 /> 257 251 </ContentHider> 258 252 ) : null} 259 - {needsTranslation && ( 260 - <View style={[pal.borderDark, styles.translateLink]}> 261 - <Link href={translatorUrl} title="Translate"> 262 - <Text type="sm" style={pal.link}> 263 - Translate this post 264 - </Text> 265 - </Link> 266 - </View> 267 - )} 268 253 </ContentHider> 269 254 <PostCtrls 270 255 itemUri={itemUri} ··· 321 306 flexDirection: 'row', 322 307 alignItems: 'center', 323 308 flexWrap: 'wrap', 324 - }, 325 - translateLink: { 326 - marginBottom: 12, 327 309 }, 328 310 replyLine: { 329 311 position: 'absolute',
+31 -26
src/view/com/posts/FeedItem.tsx
··· 8 8 FontAwesomeIconStyle, 9 9 } from '@fortawesome/react-native-fontawesome' 10 10 import {PostsFeedItemModel} from 'state/models/feeds/post' 11 + import {FeedSourceInfo} from 'lib/api/feed/types' 11 12 import {Link, DesktopWebTextLink} from '../util/Link' 12 13 import {Text} from '../util/text/Text' 13 14 import {UserInfoText} from '../util/UserInfoText' ··· 26 27 import {useAnalytics} from 'lib/analytics/analytics' 27 28 import {sanitizeDisplayName} from 'lib/strings/display-names' 28 29 import {sanitizeHandle} from 'lib/strings/handles' 29 - import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 30 + import {getTranslatorLink} from '../../../locale/helpers' 30 31 import {makeProfileLink} from 'lib/routes/links' 31 32 import {isEmbedByEmbedder} from 'lib/embeds' 32 33 33 34 export const FeedItem = observer(function FeedItemImpl({ 34 35 item, 36 + source, 35 37 isThreadChild, 36 38 isThreadLastChild, 37 39 isThreadParent, 38 40 }: { 39 41 item: PostsFeedItemModel 42 + source?: FeedSourceInfo 40 43 isThreadChild?: boolean 41 44 isThreadLastChild?: boolean 42 45 isThreadParent?: boolean ··· 62 65 return urip.hostname 63 66 }, [record?.reply]) 64 67 const translatorUrl = getTranslatorLink(record?.text || '') 65 - const needsTranslation = useMemo( 66 - () => 67 - store.preferences.contentLanguages.length > 0 && 68 - !isPostInLanguage(item.post, store.preferences.contentLanguages), 69 - [item.post, store.preferences.contentLanguages], 70 - ) 71 68 72 69 const onPressReply = React.useCallback(() => { 73 70 track('FeedItem:PostReply') ··· 179 176 </View> 180 177 181 178 <View style={{paddingTop: 12}}> 182 - {item.reasonRepost && ( 179 + {source ? ( 180 + <Link 181 + title={sanitizeDisplayName(source.displayName)} 182 + href={source.uri}> 183 + <Text 184 + type="sm-bold" 185 + style={pal.textLight} 186 + lineHeight={1.2} 187 + numberOfLines={1}> 188 + From{' '} 189 + <DesktopWebTextLink 190 + type="sm-bold" 191 + style={pal.textLight} 192 + lineHeight={1.2} 193 + numberOfLines={1} 194 + text={sanitizeDisplayName(source.displayName)} 195 + href={source.uri} 196 + /> 197 + </Text> 198 + </Link> 199 + ) : item.reasonRepost ? ( 183 200 <Link 184 201 style={styles.includeReason} 185 202 href={makeProfileLink(item.reasonRepost.by)} ··· 188 205 )}> 189 206 <FontAwesomeIcon 190 207 icon="retweet" 191 - style={[ 192 - styles.includeReasonIcon, 193 - {color: pal.colors.textLight} as FontAwesomeIconStyle, 194 - ]} 208 + style={{ 209 + marginRight: 4, 210 + color: pal.colors.textLight, 211 + }} 195 212 /> 196 213 <Text 197 214 type="sm-bold" ··· 212 229 /> 213 230 </Text> 214 231 </Link> 215 - )} 232 + ) : null} 216 233 </View> 217 234 </View> 218 235 ··· 304 321 /> 305 322 </ContentHider> 306 323 ) : null} 307 - {needsTranslation && ( 308 - <View style={[pal.borderDark, styles.translateLink]}> 309 - <Link href={translatorUrl} title="Translate"> 310 - <Text type="sm" style={pal.link}> 311 - Translate this post 312 - </Text> 313 - </Link> 314 - </View> 315 - )} 316 324 </ContentHider> 317 325 <PostCtrls 318 326 itemUri={itemUri} ··· 362 370 includeReason: { 363 371 flexDirection: 'row', 364 372 marginTop: 2, 365 - marginBottom: 4, 373 + marginBottom: 2, 366 374 marginLeft: -20, 367 - }, 368 - includeReasonIcon: { 369 - marginRight: 4, 370 375 }, 371 376 layout: { 372 377 flexDirection: 'row',
+2
src/view/com/posts/FeedSlice.tsx
··· 28 28 <FeedItem 29 29 key={slice.items[0]._reactKey} 30 30 item={slice.items[0]} 31 + source={slice.source} 31 32 isThreadParent={slice.isThreadParentAt(0)} 32 33 isThreadChild={slice.isThreadChildAt(0)} 33 34 /> ··· 55 56 <FeedItem 56 57 key={item._reactKey} 57 58 item={item} 59 + source={i === 0 ? slice.source : undefined} 58 60 isThreadParent={slice.isThreadParentAt(i)} 59 61 isThreadChild={slice.isThreadChildAt(i)} 60 62 isThreadLastChild={
+1 -1
src/view/com/posts/FollowingEmptyState.tsx
··· 28 28 }, [navigation]) 29 29 30 30 const onPressDiscoverFeeds = React.useCallback(() => { 31 - navigation.navigate('DiscoverFeeds') 31 + navigation.navigate('Feeds') 32 32 }, [navigation]) 33 33 34 34 return (
-256
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/analytics' 23 - import {usePalette} from 'lib/hooks/usePalette' 24 - import {useTheme} from 'lib/ThemeContext' 25 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 26 - import {CogIcon} from 'lib/icons' 27 - 28 - export const MultiFeed = observer(function Feed({ 29 - multifeed, 30 - style, 31 - scrollElRef, 32 - onScroll, 33 - scrollEventThrottle, 34 - testID, 35 - headerOffset = 0, 36 - extraData, 37 - }: { 38 - multifeed: PostsMultiFeedModel 39 - style?: StyleProp<ViewStyle> 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 theme = useTheme() 51 - const {isMobile} = useWebMediaQueries() 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 - if (!isMobile) { 85 - return ( 86 - <> 87 - <View style={[pal.view, pal.border, styles.headerDesktop]}> 88 - <Text type="2xl-bold" style={pal.text}> 89 - My Feeds 90 - </Text> 91 - <Link href="/settings/saved-feeds"> 92 - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> 93 - </Link> 94 - </View> 95 - <DiscoverLink /> 96 - </> 97 - ) 98 - } 99 - return ( 100 - <> 101 - <View style={[styles.header, pal.border]} /> 102 - <DiscoverLink /> 103 - </> 104 - ) 105 - } else if (item.type === 'feed-header') { 106 - return ( 107 - <View style={styles.feedHeader}> 108 - <UserAvatar type="algo" avatar={item.avatar} size={28} /> 109 - <Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}> 110 - {item.title} 111 - </Text> 112 - </View> 113 - ) 114 - } else if (item.type === 'feed-slice') { 115 - return <FeedSlice slice={item.slice} /> 116 - } else if (item.type === 'feed-loading') { 117 - return <PostFeedLoadingPlaceholder /> 118 - } else if (item.type === 'feed-error') { 119 - return <ErrorMessage message={item.error} /> 120 - } else if (item.type === 'feed-footer') { 121 - return ( 122 - <Link 123 - href={item.uri} 124 - style={[styles.feedFooter, pal.border, pal.view]}> 125 - <Text type="lg" style={pal.link}> 126 - See more from {item.title} 127 - </Text> 128 - <FontAwesomeIcon 129 - icon="angle-right" 130 - size={18} 131 - color={pal.colors.link} 132 - /> 133 - </Link> 134 - ) 135 - } else if (item.type === 'footer') { 136 - return <DiscoverLink /> 137 - } 138 - return null 139 - }, 140 - [pal, isMobile], 141 - ) 142 - 143 - const ListFooter = React.useCallback( 144 - () => 145 - multifeed.isLoading && !isRefreshing ? ( 146 - <View style={styles.loadMore}> 147 - <ActivityIndicator color={pal.colors.text} /> 148 - </View> 149 - ) : ( 150 - <View /> 151 - ), 152 - [multifeed.isLoading, isRefreshing, pal], 153 - ) 154 - 155 - return ( 156 - <View testID={testID} style={style}> 157 - {multifeed.items.length > 0 && ( 158 - <FlatList 159 - testID={testID ? `${testID}-flatlist` : undefined} 160 - ref={scrollElRef} 161 - data={multifeed.items} 162 - keyExtractor={item => item._reactKey} 163 - renderItem={renderItem} 164 - ListFooterComponent={ListFooter} 165 - refreshControl={ 166 - <RefreshControl 167 - refreshing={isRefreshing} 168 - onRefresh={onRefresh} 169 - tintColor={pal.colors.text} 170 - titleColor={pal.colors.text} 171 - progressViewOffset={headerOffset} 172 - /> 173 - } 174 - contentContainerStyle={s.contentContainer} 175 - style={[{paddingTop: headerOffset}, pal.view, styles.container]} 176 - onScroll={onScroll} 177 - scrollEventThrottle={scrollEventThrottle} 178 - indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} 179 - onEndReached={onEndReached} 180 - onEndReachedThreshold={0.6} 181 - removeClippedSubviews={true} 182 - contentOffset={{x: 0, y: headerOffset * -1}} 183 - extraData={extraData} 184 - // @ts-ignore our .web version only -prf 185 - desktopFixedHeight 186 - /> 187 - )} 188 - </View> 189 - ) 190 - }) 191 - 192 - function DiscoverLink() { 193 - const pal = usePalette('default') 194 - return ( 195 - <Link style={[styles.discoverLink, pal.viewLight]} href="/search/feeds"> 196 - <FontAwesomeIcon icon="search" size={18} color={pal.colors.text} /> 197 - <Text type="xl-medium" style={pal.text}> 198 - Discover new feeds 199 - </Text> 200 - </Link> 201 - ) 202 - } 203 - 204 - const styles = StyleSheet.create({ 205 - container: { 206 - height: '100%', 207 - }, 208 - header: { 209 - borderTopWidth: 1, 210 - marginBottom: 4, 211 - }, 212 - headerDesktop: { 213 - flexDirection: 'row', 214 - alignItems: 'center', 215 - justifyContent: 'space-between', 216 - borderBottomWidth: 1, 217 - marginBottom: 4, 218 - paddingHorizontal: 16, 219 - paddingVertical: 8, 220 - }, 221 - feedHeader: { 222 - flexDirection: 'row', 223 - gap: 8, 224 - alignItems: 'center', 225 - paddingHorizontal: 16, 226 - paddingBottom: 8, 227 - marginTop: 12, 228 - }, 229 - feedHeaderTitle: { 230 - fontWeight: 'bold', 231 - }, 232 - feedFooter: { 233 - flexDirection: 'row', 234 - justifyContent: 'space-between', 235 - alignItems: 'center', 236 - paddingHorizontal: 16, 237 - paddingVertical: 16, 238 - marginBottom: 12, 239 - borderTopWidth: 1, 240 - borderBottomWidth: 1, 241 - }, 242 - discoverLink: { 243 - flexDirection: 'row', 244 - alignItems: 'center', 245 - justifyContent: 'center', 246 - borderRadius: 8, 247 - paddingHorizontal: 14, 248 - paddingVertical: 12, 249 - marginHorizontal: 8, 250 - marginVertical: 8, 251 - gap: 8, 252 - }, 253 - loadMore: { 254 - paddingTop: 10, 255 - }, 256 - })
+12 -3
src/view/com/util/Link.tsx
··· 26 26 import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers' 27 27 import {isAndroid, isDesktopWeb} from 'platform/detection' 28 28 import {sanitizeUrl} from '@braintree/sanitize-url' 29 + import {PressableWithHover} from './PressableWithHover' 29 30 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' 30 31 31 32 type Event = ··· 38 39 href?: string 39 40 title?: string 40 41 children?: React.ReactNode 42 + hoverStyle?: StyleProp<ViewStyle> 41 43 noFeedback?: boolean 42 44 asAnchor?: boolean 43 45 anchorNoUnderline?: boolean ··· 112 114 props.accessibilityLabel = title 113 115 } 114 116 117 + const Com = props.hoverStyle ? PressableWithHover : Pressable 115 118 return ( 116 - <Pressable 119 + <Com 117 120 testID={testID} 118 121 style={style} 119 122 onPress={onPress} ··· 123 126 href={asAnchor ? sanitizeUrl(href) : undefined} 124 127 {...props}> 125 128 {children ? children : <Text>{title || 'link'}</Text>} 126 - </Pressable> 129 + </Com> 127 130 ) 128 131 }) 129 132 ··· 137 140 lineHeight, 138 141 dataSet, 139 142 title, 143 + onPress, 140 144 }: { 141 145 testID?: string 142 146 type?: TypographyVariant ··· 154 158 155 159 props.onPress = React.useCallback( 156 160 (e?: Event) => { 161 + if (onPress) { 162 + e?.preventDefault?.() 163 + // @ts-ignore function signature differs by platform -prf 164 + return onPress() 165 + } 157 166 return onPressInner(store, navigation, sanitizeUrl(href), e) 158 167 }, 159 - [store, navigation, href], 168 + [onPress, store, navigation, href], 160 169 ) 161 170 const hrefAttrs = useMemo(() => { 162 171 const isExternal = isExternalUrl(href)
+54
src/view/com/util/LoadingPlaceholder.tsx
··· 174 174 ) 175 175 } 176 176 177 + export function FeedLoadingPlaceholder({ 178 + style, 179 + }: { 180 + style?: StyleProp<ViewStyle> 181 + }) { 182 + const pal = usePalette('default') 183 + return ( 184 + <View 185 + style={[ 186 + {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1}, 187 + pal.border, 188 + style, 189 + ]}> 190 + <View style={[pal.view, {flexDirection: 'row', marginBottom: 10}]}> 191 + <LoadingPlaceholder 192 + width={36} 193 + height={36} 194 + style={[styles.avatar, {borderRadius: 6}]} 195 + /> 196 + <View style={[s.flex1]}> 197 + <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> 198 + <LoadingPlaceholder width={120} height={8} /> 199 + </View> 200 + </View> 201 + <View style={{paddingHorizontal: 5}}> 202 + <LoadingPlaceholder 203 + width={260} 204 + height={8} 205 + style={{marginVertical: 12}} 206 + /> 207 + <LoadingPlaceholder width={120} height={8} /> 208 + </View> 209 + </View> 210 + ) 211 + } 212 + 213 + export function FeedFeedLoadingPlaceholder() { 214 + return ( 215 + <> 216 + <FeedLoadingPlaceholder /> 217 + <FeedLoadingPlaceholder /> 218 + <FeedLoadingPlaceholder /> 219 + <FeedLoadingPlaceholder /> 220 + <FeedLoadingPlaceholder /> 221 + <FeedLoadingPlaceholder /> 222 + <FeedLoadingPlaceholder /> 223 + <FeedLoadingPlaceholder /> 224 + <FeedLoadingPlaceholder /> 225 + <FeedLoadingPlaceholder /> 226 + <FeedLoadingPlaceholder /> 227 + </> 228 + ) 229 + } 230 + 177 231 const styles = StyleSheet.create({ 178 232 loadingPlaceholder: { 179 233 borderRadius: 6,
+105
src/view/com/util/SimpleViewHeader.tsx
··· 1 + import React from 'react' 2 + import {observer} from 'mobx-react-lite' 3 + import { 4 + StyleProp, 5 + StyleSheet, 6 + TouchableOpacity, 7 + View, 8 + ViewStyle, 9 + } from 'react-native' 10 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 + import {useNavigation} from '@react-navigation/native' 12 + import {CenteredView} from './Views' 13 + import {useStores} from 'state/index' 14 + import {usePalette} from 'lib/hooks/usePalette' 15 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 16 + import {useAnalytics} from 'lib/analytics/analytics' 17 + import {NavigationProp} from 'lib/routes/types' 18 + 19 + const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} 20 + 21 + export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ 22 + showBackButton = true, 23 + style, 24 + children, 25 + }: React.PropsWithChildren<{ 26 + showBackButton?: boolean 27 + style?: StyleProp<ViewStyle> 28 + }>) { 29 + const pal = usePalette('default') 30 + const store = useStores() 31 + const navigation = useNavigation<NavigationProp>() 32 + const {track} = useAnalytics() 33 + const {isMobile} = useWebMediaQueries() 34 + const canGoBack = navigation.canGoBack() 35 + 36 + const onPressBack = React.useCallback(() => { 37 + if (navigation.canGoBack()) { 38 + navigation.goBack() 39 + } else { 40 + navigation.navigate('Home') 41 + } 42 + }, [navigation]) 43 + 44 + const onPressMenu = React.useCallback(() => { 45 + track('ViewHeader:MenuButtonClicked') 46 + store.shell.openDrawer() 47 + }, [track, store]) 48 + 49 + const Container = isMobile ? View : CenteredView 50 + return ( 51 + <Container style={[styles.header, isMobile && styles.headerMobile, style]}> 52 + {showBackButton ? ( 53 + <TouchableOpacity 54 + testID="viewHeaderDrawerBtn" 55 + onPress={canGoBack ? onPressBack : onPressMenu} 56 + hitSlop={BACK_HITSLOP} 57 + style={canGoBack ? styles.backBtn : styles.backBtnWide} 58 + accessibilityRole="button" 59 + accessibilityLabel={canGoBack ? 'Back' : 'Menu'} 60 + accessibilityHint=""> 61 + {canGoBack ? ( 62 + <FontAwesomeIcon 63 + size={18} 64 + icon="angle-left" 65 + style={[styles.backIcon, pal.text]} 66 + /> 67 + ) : ( 68 + <FontAwesomeIcon 69 + size={18} 70 + icon="bars" 71 + style={[styles.backIcon, pal.textLight]} 72 + /> 73 + )} 74 + </TouchableOpacity> 75 + ) : null} 76 + {children} 77 + </Container> 78 + ) 79 + }) 80 + 81 + const styles = StyleSheet.create({ 82 + header: { 83 + flexDirection: 'row', 84 + alignItems: 'center', 85 + paddingHorizontal: 18, 86 + paddingVertical: 12, 87 + width: '100%', 88 + }, 89 + headerMobile: { 90 + paddingHorizontal: 12, 91 + paddingVertical: 10, 92 + }, 93 + backBtn: { 94 + width: 30, 95 + height: 30, 96 + }, 97 + backBtnWide: { 98 + width: 30, 99 + height: 30, 100 + paddingHorizontal: 6, 101 + }, 102 + backIcon: { 103 + marginTop: 6, 104 + }, 105 + })
+1 -1
src/view/com/util/UserAvatar.tsx
··· 118 118 return { 119 119 width: size, 120 120 height: size, 121 - borderRadius: 8, 121 + borderRadius: size > 32 ? 8 : 3, 122 122 } 123 123 } 124 124 return {
+104
src/view/com/util/forms/SearchInput.tsx
··· 1 + import React from 'react' 2 + import { 3 + StyleProp, 4 + StyleSheet, 5 + TextInput, 6 + TouchableOpacity, 7 + View, 8 + ViewStyle, 9 + } from 'react-native' 10 + import { 11 + FontAwesomeIcon, 12 + FontAwesomeIconStyle, 13 + } from '@fortawesome/react-native-fontawesome' 14 + import {MagnifyingGlassIcon} from 'lib/icons' 15 + import {useTheme} from 'lib/ThemeContext' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + 18 + interface Props { 19 + query: string 20 + setIsInputFocused?: (v: boolean) => void 21 + onChangeQuery: (v: string) => void 22 + onPressCancelSearch: () => void 23 + onSubmitQuery: () => void 24 + style?: StyleProp<ViewStyle> 25 + } 26 + export function SearchInput({ 27 + query, 28 + setIsInputFocused, 29 + onChangeQuery, 30 + onPressCancelSearch, 31 + onSubmitQuery, 32 + style, 33 + }: Props) { 34 + const theme = useTheme() 35 + const pal = usePalette('default') 36 + const textInput = React.useRef<TextInput>(null) 37 + 38 + const onPressCancelSearchInner = React.useCallback(() => { 39 + onPressCancelSearch() 40 + textInput.current?.blur() 41 + }, [onPressCancelSearch, textInput]) 42 + 43 + return ( 44 + <View style={[pal.viewLight, styles.container, style]}> 45 + <MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} /> 46 + <TextInput 47 + testID="searchTextInput" 48 + ref={textInput} 49 + placeholder="Search" 50 + placeholderTextColor={pal.colors.textLight} 51 + selectTextOnFocus 52 + returnKeyType="search" 53 + value={query} 54 + style={[pal.text, styles.input]} 55 + keyboardAppearance={theme.colorScheme} 56 + onFocus={() => setIsInputFocused?.(true)} 57 + onBlur={() => setIsInputFocused?.(false)} 58 + onChangeText={onChangeQuery} 59 + onSubmitEditing={onSubmitQuery} 60 + accessibilityRole="search" 61 + accessibilityLabel="Search" 62 + accessibilityHint="" 63 + autoCorrect={false} 64 + autoCapitalize="none" 65 + /> 66 + {query ? ( 67 + <TouchableOpacity 68 + onPress={onPressCancelSearchInner} 69 + accessibilityRole="button" 70 + accessibilityLabel="Clear search query" 71 + accessibilityHint=""> 72 + <FontAwesomeIcon 73 + icon="xmark" 74 + size={16} 75 + style={pal.textLight as FontAwesomeIconStyle} 76 + /> 77 + </TouchableOpacity> 78 + ) : undefined} 79 + </View> 80 + ) 81 + } 82 + 83 + const styles = StyleSheet.create({ 84 + container: { 85 + flex: 1, 86 + flexDirection: 'row', 87 + alignItems: 'center', 88 + borderRadius: 30, 89 + paddingHorizontal: 12, 90 + paddingVertical: 8, 91 + }, 92 + icon: { 93 + marginRight: 6, 94 + alignSelf: 'center', 95 + }, 96 + input: { 97 + flex: 1, 98 + fontSize: 17, 99 + minWidth: 0, // overflow mitigation for firefox 100 + }, 101 + cancelBtn: { 102 + paddingLeft: 10, 103 + }, 104 + })
+86 -1
src/view/com/util/load-latest/LoadLatestBtn.tsx
··· 1 - export * from './LoadLatestBtnMobile' 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import {observer} from 'mobx-react-lite' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 + import {clamp} from 'lodash' 7 + import {useStores} from 'state/index' 8 + import {usePalette} from 'lib/hooks/usePalette' 9 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 10 + import {colors} from 'lib/styles' 11 + import {HITSLOP_20} from 'lib/constants' 12 + 13 + export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ 14 + onPress, 15 + label, 16 + showIndicator, 17 + }: { 18 + onPress: () => void 19 + label: string 20 + showIndicator: boolean 21 + minimalShellMode?: boolean // NOTE not used on mobile -prf 22 + }) { 23 + const store = useStores() 24 + const pal = usePalette('default') 25 + const {isDesktop, isTablet, isMobile} = useWebMediaQueries() 26 + const safeAreaInsets = useSafeAreaInsets() 27 + return ( 28 + <TouchableOpacity 29 + style={[ 30 + styles.loadLatest, 31 + isDesktop && styles.loadLatestDesktop, 32 + isTablet && styles.loadLatestTablet, 33 + pal.borderDark, 34 + pal.view, 35 + isMobile && 36 + !store.shell.minimalShellMode && { 37 + bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), 38 + }, 39 + ]} 40 + onPress={onPress} 41 + hitSlop={HITSLOP_20} 42 + accessibilityRole="button" 43 + accessibilityLabel={label} 44 + accessibilityHint=""> 45 + <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> 46 + {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} 47 + </TouchableOpacity> 48 + ) 49 + }) 50 + 51 + const styles = StyleSheet.create({ 52 + loadLatest: { 53 + position: 'absolute', 54 + left: 18, 55 + bottom: 35, 56 + borderWidth: 1, 57 + width: 52, 58 + height: 52, 59 + borderRadius: 26, 60 + flexDirection: 'row', 61 + alignItems: 'center', 62 + justifyContent: 'center', 63 + }, 64 + loadLatestTablet: { 65 + // @ts-ignore web only 66 + left: '50vw', 67 + // @ts-ignore web only -prf 68 + transform: 'translateX(-282px)', 69 + }, 70 + loadLatestDesktop: { 71 + // @ts-ignore web only 72 + left: '50vw', 73 + // @ts-ignore web only -prf 74 + transform: 'translateX(-382px)', 75 + }, 76 + indicator: { 77 + position: 'absolute', 78 + top: 3, 79 + right: 3, 80 + backgroundColor: colors.blue3, 81 + width: 12, 82 + height: 12, 83 + borderRadius: 6, 84 + borderWidth: 1, 85 + }, 86 + })
-109
src/view/com/util/load-latest/LoadLatestBtn.web.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TouchableOpacity} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {Text} from '../text/Text' 5 - import {usePalette} from 'lib/hooks/usePalette' 6 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 7 - import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile' 8 - import {HITSLOP_20} from 'lib/constants' 9 - 10 - export const LoadLatestBtn = ({ 11 - onPress, 12 - label, 13 - showIndicator, 14 - minimalShellMode, 15 - }: { 16 - onPress: () => void 17 - label: string 18 - showIndicator: boolean 19 - minimalShellMode?: boolean 20 - }) => { 21 - const pal = usePalette('default') 22 - const {isMobile} = useWebMediaQueries() 23 - if (isMobile) { 24 - return ( 25 - <LoadLatestBtnMobile 26 - onPress={onPress} 27 - label={label} 28 - showIndicator={showIndicator} 29 - /> 30 - ) 31 - } 32 - return ( 33 - <> 34 - {showIndicator && ( 35 - <TouchableOpacity 36 - style={[ 37 - pal.view, 38 - pal.borderDark, 39 - styles.loadLatestCentered, 40 - minimalShellMode && styles.loadLatestCenteredMinimal, 41 - ]} 42 - onPress={onPress} 43 - hitSlop={HITSLOP_20} 44 - accessibilityRole="button" 45 - accessibilityLabel={label} 46 - accessibilityHint=""> 47 - <Text type="md-bold" style={pal.text}> 48 - {label} 49 - </Text> 50 - </TouchableOpacity> 51 - )} 52 - <TouchableOpacity 53 - style={[pal.view, pal.borderDark, styles.loadLatest]} 54 - onPress={onPress} 55 - hitSlop={HITSLOP_20} 56 - accessibilityRole="button" 57 - accessibilityLabel={label} 58 - accessibilityHint=""> 59 - <Text type="md-bold" style={pal.text}> 60 - <FontAwesomeIcon 61 - icon="angle-up" 62 - size={21} 63 - style={[pal.text, styles.icon]} 64 - /> 65 - </Text> 66 - </TouchableOpacity> 67 - </> 68 - ) 69 - } 70 - 71 - const styles = StyleSheet.create({ 72 - loadLatest: { 73 - flexDirection: 'row', 74 - alignItems: 'center', 75 - justifyContent: 'center', 76 - position: 'absolute', 77 - // @ts-ignore web only 78 - left: '50vw', 79 - // @ts-ignore web only -prf 80 - transform: 'translateX(-282px)', 81 - bottom: 40, 82 - width: 54, 83 - height: 54, 84 - borderRadius: 30, 85 - borderWidth: 1, 86 - }, 87 - icon: { 88 - position: 'relative', 89 - top: 2, 90 - }, 91 - loadLatestCentered: { 92 - flexDirection: 'row', 93 - alignItems: 'center', 94 - justifyContent: 'center', 95 - position: 'absolute', 96 - // @ts-ignore web only 97 - left: '50vw', 98 - // @ts-ignore web only -prf 99 - transform: 'translateX(-50%)', 100 - top: 60, 101 - paddingHorizontal: 24, 102 - paddingVertical: 14, 103 - borderRadius: 30, 104 - borderWidth: 1, 105 - }, 106 - loadLatestCenteredMinimal: { 107 - top: 20, 108 - }, 109 - })
-69
src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 - import {clamp} from 'lodash' 7 - import {useStores} from 'state/index' 8 - import {usePalette} from 'lib/hooks/usePalette' 9 - import {colors} from 'lib/styles' 10 - import {HITSLOP_20} from 'lib/constants' 11 - 12 - export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ 13 - onPress, 14 - label, 15 - showIndicator, 16 - }: { 17 - onPress: () => void 18 - label: string 19 - showIndicator: boolean 20 - minimalShellMode?: boolean // NOTE not used on mobile -prf 21 - }) { 22 - const store = useStores() 23 - const pal = usePalette('default') 24 - const safeAreaInsets = useSafeAreaInsets() 25 - return ( 26 - <TouchableOpacity 27 - style={[ 28 - styles.loadLatest, 29 - pal.borderDark, 30 - pal.view, 31 - !store.shell.minimalShellMode && { 32 - bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), 33 - }, 34 - ]} 35 - onPress={onPress} 36 - hitSlop={HITSLOP_20} 37 - accessibilityRole="button" 38 - accessibilityLabel={label} 39 - accessibilityHint=""> 40 - <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> 41 - {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} 42 - </TouchableOpacity> 43 - ) 44 - }) 45 - 46 - const styles = StyleSheet.create({ 47 - loadLatest: { 48 - position: 'absolute', 49 - left: 18, 50 - bottom: 35, 51 - borderWidth: 1, 52 - width: 52, 53 - height: 52, 54 - borderRadius: 26, 55 - flexDirection: 'row', 56 - alignItems: 'center', 57 - justifyContent: 'center', 58 - }, 59 - indicator: { 60 - position: 'absolute', 61 - top: 3, 62 - right: 3, 63 - backgroundColor: colors.blue3, 64 - width: 12, 65 - height: 12, 66 - borderRadius: 6, 67 - borderWidth: 1, 68 - }, 69 - })
+14 -10
src/view/index.ts
··· 13 13 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' 14 14 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' 15 15 import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft' 16 + import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp' 16 17 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' 17 18 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' 18 19 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' ··· 24 25 import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' 25 26 import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera' 26 27 import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck' 28 + import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' 27 29 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' 28 30 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' 29 31 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' ··· 41 43 import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' 42 44 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' 43 45 import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile' 46 + import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' 44 47 import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk' 45 48 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' 46 49 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' ··· 54 57 import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' 55 58 import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' 56 59 import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' 60 + import {faList} from '@fortawesome/free-solid-svg-icons/faList' 57 61 import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' 58 62 import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' 59 63 import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' 60 64 import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' 61 65 import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky' 66 + import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' 62 67 import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste' 63 68 import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' 64 69 import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' 65 70 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' 71 + import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' 66 72 import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' 67 73 import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' 68 74 import {faReply} from '@fortawesome/free-solid-svg-icons/faReply' ··· 77 83 import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare' 78 84 import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck' 79 85 import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' 86 + import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' 80 87 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' 81 88 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' 82 89 import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' ··· 88 95 import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' 89 96 import {faX} from '@fortawesome/free-solid-svg-icons/faX' 90 97 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' 91 - import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' 92 - import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' 93 - import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' 94 - import {faList} from '@fortawesome/free-solid-svg-icons/faList' 95 - import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' 96 98 97 99 export function setup() { 98 100 library.add( ··· 109 111 faArrowUpFromBracket, 110 112 faArrowUpRightFromSquare, 111 113 faArrowRotateLeft, 114 + faArrowTrendUp, 112 115 faArrowsRotate, 113 116 faAt, 114 117 faBan, ··· 120 123 farCalendar, 121 124 faCamera, 122 125 faCheck, 126 + faChevronRight, 123 127 faCircle, 124 128 faCircleCheck, 125 129 farCircleCheck, ··· 137 141 faExclamation, 138 142 farEyeSlash, 139 143 faFaceSmile, 144 + faFire, 140 145 faFloppyDisk, 141 146 faGear, 142 147 faGlobe, ··· 150 155 faInfo, 151 156 faLanguage, 152 157 faLink, 158 + faList, 153 159 faListUl, 154 160 faLock, 155 161 faMagnifyingGlass, 156 162 faMessage, 157 163 faNoteSticky, 158 164 faPaste, 165 + faPause, 159 166 faPen, 160 167 faPenNib, 161 168 faPenToSquare, 169 + faPlay, 162 170 faPlus, 163 171 faQuoteLeft, 164 172 faReply, ··· 180 188 faUserPlus, 181 189 faUserXmark, 182 190 faUsersSlash, 191 + faThumbtack, 183 192 faTicket, 184 193 faTrashCan, 185 - faThumbtack, 186 194 faX, 187 195 faXmark, 188 - faPlay, 189 - faPause, 190 - faList, 191 - faChevronRight, 192 196 ) 193 197 }
+133 -258
src/view/screens/CustomFeed.tsx
··· 1 1 import React, {useMemo, useRef} from 'react' 2 2 import {NativeStackScreenProps} from '@react-navigation/native-stack' 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {useNavigation} from '@react-navigation/native' 4 + import {useNavigation, useIsFocused} from '@react-navigation/native' 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 6 import {HeartIcon, HeartIconSolid} from 'lib/icons' 7 7 import {CommonNavigatorParams} from 'lib/routes/types' ··· 14 14 import {useCustomFeed} from 'lib/hooks/useCustomFeed' 15 15 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 16 16 import {Feed} from 'view/com/posts/Feed' 17 - import {pluralize} from 'lib/strings/helpers' 18 - import {sanitizeHandle} from 'lib/strings/handles' 19 17 import {TextLink} from 'view/com/util/Link' 20 - import {UserAvatar} from 'view/com/util/UserAvatar' 21 - import {ViewHeader} from 'view/com/util/ViewHeader' 18 + import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 22 19 import {Button} from 'view/com/util/forms/Button' 23 20 import {Text} from 'view/com/util/text/Text' 24 21 import * as Toast from 'view/com/util/Toast' ··· 34 31 import {EmptyState} from 'view/com/util/EmptyState' 35 32 import {useAnalytics} from 'lib/analytics/analytics' 36 33 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' 37 - import {makeProfileLink} from 'lib/routes/links' 38 34 import {resolveName} from 'lib/api' 39 35 import {CenteredView} from 'view/com/util/Views' 40 36 import {NavigationProp} from 'lib/routes/types' ··· 125 121 }: Props & {feedOwnerDid: string}) { 126 122 const store = useStores() 127 123 const pal = usePalette('default') 128 - const {isTabletOrDesktop} = useWebMediaQueries() 124 + const palInverted = usePalette('inverted') 125 + const navigation = useNavigation<NavigationProp>() 126 + const isScreenFocused = useIsFocused() 127 + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() 129 128 const {track} = useAnalytics() 130 129 const {rkey, name: handleOrDid} = route.params 131 130 const uri = useMemo( ··· 186 185 }) 187 186 }, [store, currentFeed]) 188 187 188 + const onPressViewAuthor = React.useCallback(() => { 189 + navigation.navigate('Profile', {name: handleOrDid}) 190 + }, [handleOrDid, navigation]) 191 + 189 192 const onPressShare = React.useCallback(() => { 190 193 const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) 191 194 shareUrl(url) ··· 210 213 store.shell.openComposer({}) 211 214 }, [store]) 212 215 216 + const onSoftReset = React.useCallback(() => { 217 + if (isScreenFocused) { 218 + onScrollToTop() 219 + algoFeed.refresh() 220 + } 221 + }, [isScreenFocused, onScrollToTop, algoFeed]) 222 + 223 + // fires when page within screen is activated/deactivated 224 + React.useEffect(() => { 225 + if (!isScreenFocused) { 226 + return 227 + } 228 + 229 + const softResetSub = store.onScreenSoftReset(onSoftReset) 230 + return () => { 231 + softResetSub.remove() 232 + } 233 + }, [store, onSoftReset, isScreenFocused]) 234 + 213 235 const dropdownItems: DropdownItem[] = React.useMemo(() => { 214 236 let items: DropdownItem[] = [ 237 + { 238 + testID: 'feedHeaderDropdownViewAuthorBtn', 239 + label: 'View author', 240 + onPress: onPressViewAuthor, 241 + icon: { 242 + ios: { 243 + name: 'person', 244 + }, 245 + android: '', 246 + web: ['far', 'user'], 247 + }, 248 + }, 215 249 { 216 250 testID: 'feedHeaderDropdownToggleSavedBtn', 217 251 label: currentFeed?.isSaved ··· 260 294 }, 261 295 ] 262 296 return items 263 - }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare]) 297 + }, [ 298 + currentFeed?.isSaved, 299 + onToggleSaved, 300 + onPressReport, 301 + onPressShare, 302 + onPressViewAuthor, 303 + ]) 264 304 265 - const renderHeaderBtns = React.useCallback(() => { 305 + const renderEmptyState = React.useCallback(() => { 266 306 return ( 267 - <View style={styles.headerBtns}> 268 - <Button 269 - type="default-light" 270 - testID="toggleLikeBtn" 271 - accessibilityLabel="Like this feed" 272 - accessibilityHint="" 273 - onPress={onToggleLiked}> 274 - {currentFeed?.isLiked ? ( 275 - <HeartIconSolid size={19} style={styles.liked} /> 307 + <View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}> 308 + <EmptyState icon="feed" message="This feed is empty!" /> 309 + </View> 310 + ) 311 + }, [pal.border]) 312 + 313 + return ( 314 + <View style={s.hContentRegion}> 315 + <SimpleViewHeader 316 + showBackButton={isMobile} 317 + style={ 318 + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] 319 + }> 320 + <Text type="title-lg" style={styles.headerText} numberOfLines={1}> 321 + {currentFeed ? ( 322 + <TextLink 323 + type="title-lg" 324 + href="/" 325 + style={[pal.text, {fontWeight: 'bold'}]} 326 + text={currentFeed?.displayName || ''} 327 + onPress={() => store.emitScreenSoftReset()} 328 + /> 276 329 ) : ( 277 - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> 330 + 'Loading...' 278 331 )} 279 - </Button> 280 - {currentFeed?.isSaved ? ( 281 - <Button 282 - type="default-light" 283 - accessibilityLabel={ 284 - isPinned ? 'Unpin this feed' : 'Pin this feed' 285 - } 286 - accessibilityHint="" 287 - onPress={onTogglePinned}> 288 - <FontAwesomeIcon 289 - icon="thumb-tack" 290 - size={17} 291 - color={isPinned ? colors.blue3 : pal.colors.textLight} 292 - style={styles.top1} 293 - /> 294 - </Button> 295 - ) : undefined} 296 - {!currentFeed?.isSaved ? ( 297 - <Button 298 - type="default-light" 299 - onPress={onToggleSaved} 300 - accessibilityLabel="Add to my feeds" 301 - accessibilityHint="" 302 - style={styles.headerAddBtn}> 303 - <FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} /> 304 - <Text type="xl-medium" style={pal.link}> 305 - Add to My Feeds 306 - </Text> 307 - </Button> 332 + </Text> 333 + {currentFeed ? ( 334 + <> 335 + <Button 336 + type="default-light" 337 + testID="toggleLikeBtn" 338 + accessibilityLabel="Like this feed" 339 + accessibilityHint="" 340 + onPress={onToggleLiked} 341 + style={styles.headerBtn}> 342 + {currentFeed?.isLiked ? ( 343 + <HeartIconSolid size={19} style={styles.liked} /> 344 + ) : ( 345 + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> 346 + )} 347 + </Button> 348 + {currentFeed?.isSaved ? ( 349 + <Button 350 + type="default-light" 351 + accessibilityLabel={ 352 + isPinned ? 'Unpin this feed' : 'Pin this feed' 353 + } 354 + accessibilityHint="" 355 + onPress={onTogglePinned} 356 + style={styles.headerBtn}> 357 + <FontAwesomeIcon 358 + icon="thumb-tack" 359 + size={17} 360 + color={isPinned ? colors.blue3 : pal.colors.textLight} 361 + style={styles.top1} 362 + /> 363 + </Button> 364 + ) : ( 365 + <Button 366 + type="inverted" 367 + onPress={onToggleSaved} 368 + accessibilityLabel="Add to my feeds" 369 + accessibilityHint="" 370 + style={styles.headerAddBtn}> 371 + <FontAwesomeIcon 372 + icon="plus" 373 + color={palInverted.colors.text} 374 + size={19} 375 + /> 376 + <Text type="button" style={palInverted.text}> 377 + Add{!isMobile && ' to My Feeds'} 378 + </Text> 379 + </Button> 380 + )} 381 + </> 308 382 ) : null} 309 383 <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> 310 384 <View 311 385 style={{ 312 - paddingLeft: currentFeed?.isSaved ? 12 : 6, 313 - paddingRight: 12, 314 - paddingVertical: 8, 386 + paddingLeft: 12, 387 + paddingRight: isMobile ? 12 : 0, 315 388 }}> 316 389 <FontAwesomeIcon 317 390 icon="ellipsis" ··· 320 393 /> 321 394 </View> 322 395 </NativeDropdown> 323 - </View> 324 - ) 325 - }, [ 326 - pal, 327 - currentFeed?.isSaved, 328 - currentFeed?.isLiked, 329 - isPinned, 330 - onToggleSaved, 331 - onTogglePinned, 332 - onToggleLiked, 333 - dropdownItems, 334 - ]) 335 - 336 - const renderListHeaderComponent = React.useCallback(() => { 337 - return ( 338 - <> 339 - <View style={[styles.header, pal.border]}> 340 - <View style={s.flex1}> 341 - <Text 342 - testID="feedName" 343 - type="title-xl" 344 - style={[pal.text, s.bold]}> 345 - {currentFeed?.displayName} 346 - </Text> 347 - {currentFeed && ( 348 - <Text type="md" style={[pal.textLight]} numberOfLines={1}> 349 - by{' '} 350 - {currentFeed.data.creator.did === store.me.did ? ( 351 - 'you' 352 - ) : ( 353 - <TextLink 354 - text={sanitizeHandle( 355 - currentFeed.data.creator.handle, 356 - '@', 357 - )} 358 - href={makeProfileLink(currentFeed.data.creator)} 359 - style={[pal.textLight]} 360 - /> 361 - )} 362 - </Text> 363 - )} 364 - {isTabletOrDesktop && ( 365 - <View style={[styles.headerBtns, styles.headerBtnsDesktop]}> 366 - <Button 367 - type={currentFeed?.isSaved ? 'default' : 'inverted'} 368 - onPress={onToggleSaved} 369 - accessibilityLabel={ 370 - currentFeed?.isSaved 371 - ? 'Unsave this feed' 372 - : 'Save this feed' 373 - } 374 - accessibilityHint="" 375 - label={ 376 - currentFeed?.isSaved 377 - ? 'Remove from My Feeds' 378 - : 'Add to My Feeds' 379 - } 380 - /> 381 - <Button 382 - type="default" 383 - accessibilityLabel={ 384 - isPinned ? 'Unpin this feed' : 'Pin this feed' 385 - } 386 - accessibilityHint="" 387 - onPress={onTogglePinned}> 388 - <FontAwesomeIcon 389 - icon="thumb-tack" 390 - size={15} 391 - color={isPinned ? colors.blue3 : pal.colors.icon} 392 - style={styles.top2} 393 - /> 394 - </Button> 395 - <Button 396 - type="default" 397 - accessibilityLabel="Like this feed" 398 - accessibilityHint="" 399 - onPress={onToggleLiked}> 400 - {currentFeed?.isLiked ? ( 401 - <HeartIconSolid size={18} style={styles.liked} /> 402 - ) : ( 403 - <HeartIcon strokeWidth={3} size={18} style={pal.icon} /> 404 - )} 405 - </Button> 406 - <Button 407 - type="default" 408 - accessibilityLabel="Share this feed" 409 - accessibilityHint="" 410 - onPress={onPressShare}> 411 - <FontAwesomeIcon 412 - icon="share" 413 - size={18} 414 - color={pal.colors.icon} 415 - /> 416 - </Button> 417 - <Button 418 - type="default" 419 - accessibilityLabel="Report this feed" 420 - accessibilityHint="" 421 - onPress={onPressReport}> 422 - <FontAwesomeIcon 423 - icon="circle-exclamation" 424 - size={18} 425 - color={pal.colors.icon} 426 - /> 427 - </Button> 428 - </View> 429 - )} 430 - </View> 431 - <View> 432 - <UserAvatar 433 - type="algo" 434 - avatar={currentFeed?.data.avatar} 435 - size={64} 436 - /> 437 - </View> 438 - </View> 439 - <View style={styles.headerDetails}> 440 - {currentFeed?.data.description ? ( 441 - <Text style={[pal.text, s.mb10]} numberOfLines={6}> 442 - {currentFeed.data.description} 443 - </Text> 444 - ) : null} 445 - <View style={styles.headerDetailsFooter}> 446 - {currentFeed ? ( 447 - <TextLink 448 - type="md-medium" 449 - style={pal.textLight} 450 - href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`} 451 - text={`Liked by ${currentFeed.data.likeCount} ${pluralize( 452 - currentFeed?.data.likeCount || 0, 453 - 'user', 454 - )}`} 455 - /> 456 - ) : null} 457 - </View> 458 - </View> 459 - <View 460 - style={[ 461 - styles.fakeSelector, 462 - { 463 - paddingHorizontal: isTabletOrDesktop ? 16 : 6, 464 - }, 465 - pal.border, 466 - ]}> 467 - <View 468 - style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> 469 - <Text type="md-medium" style={[pal.text]}> 470 - Feed 471 - </Text> 472 - </View> 473 - </View> 474 - </> 475 - ) 476 - }, [ 477 - pal, 478 - currentFeed, 479 - store.me.did, 480 - onToggleSaved, 481 - onToggleLiked, 482 - onPressShare, 483 - handleOrDid, 484 - onPressReport, 485 - rkey, 486 - isPinned, 487 - onTogglePinned, 488 - isTabletOrDesktop, 489 - ]) 490 - 491 - const renderEmptyState = React.useCallback(() => { 492 - return ( 493 - <View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}> 494 - <EmptyState icon="feed" message="This feed is empty!" /> 495 - </View> 496 - ) 497 - }, [pal.border]) 498 - 499 - return ( 500 - <View style={s.hContentRegion}> 501 - {!isTabletOrDesktop && ( 502 - <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} /> 503 - )} 396 + </SimpleViewHeader> 504 397 <Feed 505 398 scrollElRef={scrollElRef} 506 399 feed={algoFeed} 507 400 onScroll={onMainScroll} 508 401 scrollEventThrottle={100} 509 - ListHeaderComponent={renderListHeaderComponent} 510 402 renderEmptyState={renderEmptyState} 511 403 extraData={[uri, isPinned]} 512 404 style={!isTabletOrDesktop ? {flex: 1} : undefined} 513 405 /> 514 406 {isScrolledDown ? ( 515 407 <LoadLatestBtn 516 - onPress={onScrollToTop} 408 + onPress={onSoftReset} 517 409 label="Scroll to top" 518 410 showIndicator={false} 519 411 /> ··· 540 432 paddingBottom: 16, 541 433 borderTopWidth: 1, 542 434 }, 543 - headerBtns: { 544 - flexDirection: 'row', 545 - alignItems: 'center', 435 + headerText: { 436 + flex: 1, 437 + fontWeight: 'bold', 546 438 }, 547 - headerBtnsDesktop: { 548 - marginTop: 8, 549 - gap: 4, 439 + headerBtn: { 440 + paddingVertical: 0, 550 441 }, 551 442 headerAddBtn: { 552 443 flexDirection: 'row', 553 444 alignItems: 'center', 554 445 gap: 4, 555 - paddingLeft: 4, 556 - }, 557 - headerDetails: { 558 - paddingHorizontal: 16, 559 - paddingBottom: 16, 560 - }, 561 - headerDetailsFooter: { 562 - flexDirection: 'row', 563 - alignItems: 'center', 564 - justifyContent: 'space-between', 565 - }, 566 - fakeSelector: { 567 - flexDirection: 'row', 568 - }, 569 - fakeSelectorItem: { 570 - paddingHorizontal: 12, 571 - paddingBottom: 8, 572 - borderBottomWidth: 3, 446 + paddingVertical: 4, 447 + paddingLeft: 10, 573 448 }, 574 449 liked: { 575 450 color: colors.red3,
-157
src/view/screens/DiscoverFeeds.tsx
··· 1 - import React from 'react' 2 - import {RefreshControl, StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {useFocusEffect} from '@react-navigation/native' 5 - import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 6 - import {withAuthRequired} from 'view/com/auth/withAuthRequired' 7 - import {ViewHeader} from '../com/util/ViewHeader' 8 - import {useStores} from 'state/index' 9 - import {FeedsDiscoveryModel} from 'state/models/discovery/feeds' 10 - import {CenteredView, FlatList} from 'view/com/util/Views' 11 - import {CustomFeed} from 'view/com/feeds/CustomFeed' 12 - import {Text} from 'view/com/util/text/Text' 13 - import {usePalette} from 'lib/hooks/usePalette' 14 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 15 - import {s} from 'lib/styles' 16 - import {CustomFeedModel} from 'state/models/feeds/custom-feed' 17 - import {HeaderWithInput} from 'view/com/search/HeaderWithInput' 18 - import debounce from 'lodash.debounce' 19 - 20 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'> 21 - export const DiscoverFeedsScreen = withAuthRequired( 22 - observer(function DiscoverFeedsScreenImpl({}: Props) { 23 - const store = useStores() 24 - const pal = usePalette('default') 25 - const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) 26 - const {isTabletOrDesktop} = useWebMediaQueries() 27 - 28 - // search stuff 29 - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) 30 - const [query, setQuery] = React.useState<string>('') 31 - const debouncedSearchFeeds = React.useMemo( 32 - () => debounce(q => feeds.search(q), 500), // debounce for 500ms 33 - [feeds], 34 - ) 35 - const onChangeQuery = React.useCallback( 36 - (text: string) => { 37 - setQuery(text) 38 - if (text.length > 1) { 39 - debouncedSearchFeeds(text) 40 - } else { 41 - feeds.refresh() 42 - } 43 - }, 44 - [debouncedSearchFeeds, feeds], 45 - ) 46 - const onPressClearQuery = React.useCallback(() => { 47 - setQuery('') 48 - feeds.refresh() 49 - }, [feeds]) 50 - const onPressCancelSearch = React.useCallback(() => { 51 - setIsInputFocused(false) 52 - setQuery('') 53 - feeds.refresh() 54 - }, [feeds]) 55 - const onSubmitQuery = React.useCallback(() => { 56 - debouncedSearchFeeds(query) 57 - debouncedSearchFeeds.flush() 58 - }, [debouncedSearchFeeds, query]) 59 - 60 - useFocusEffect( 61 - React.useCallback(() => { 62 - store.shell.setMinimalShellMode(false) 63 - if (!feeds.hasLoaded) { 64 - feeds.refresh() 65 - } 66 - }, [store, feeds]), 67 - ) 68 - 69 - const onRefresh = React.useCallback(() => { 70 - feeds.refresh() 71 - }, [feeds]) 72 - 73 - const renderListEmptyComponent = () => { 74 - return ( 75 - <View style={styles.empty}> 76 - <Text type="lg" style={pal.textLight}> 77 - {feeds.isLoading 78 - ? isTabletOrDesktop 79 - ? 'Loading...' 80 - : '' 81 - : query 82 - ? `No results found for "${query}"` 83 - : `We can't find any feeds for some reason. This is probably an error - try refreshing!`} 84 - </Text> 85 - </View> 86 - ) 87 - } 88 - 89 - const renderItem = React.useCallback( 90 - ({item}: {item: CustomFeedModel}) => ( 91 - <CustomFeed 92 - key={item.data.uri} 93 - item={item} 94 - showSaveBtn 95 - showDescription 96 - showLikes 97 - /> 98 - ), 99 - [], 100 - ) 101 - 102 - return ( 103 - <CenteredView style={[styles.container, pal.view]}> 104 - <View 105 - style={[isTabletOrDesktop && styles.containerDesktop, pal.border]}> 106 - <ViewHeader title="Discover Feeds" showOnDesktop /> 107 - </View> 108 - <HeaderWithInput 109 - isInputFocused={isInputFocused} 110 - query={query} 111 - setIsInputFocused={setIsInputFocused} 112 - onChangeQuery={onChangeQuery} 113 - onPressClearQuery={onPressClearQuery} 114 - onPressCancelSearch={onPressCancelSearch} 115 - onSubmitQuery={onSubmitQuery} 116 - showMenu={false} 117 - /> 118 - <FlatList 119 - style={[!isTabletOrDesktop && s.flex1]} 120 - data={feeds.feeds} 121 - keyExtractor={item => item.data.uri} 122 - contentContainerStyle={styles.contentContainer} 123 - refreshControl={ 124 - <RefreshControl 125 - refreshing={feeds.isRefreshing} 126 - onRefresh={onRefresh} 127 - tintColor={pal.colors.text} 128 - titleColor={pal.colors.text} 129 - /> 130 - } 131 - renderItem={renderItem} 132 - initialNumToRender={10} 133 - ListEmptyComponent={renderListEmptyComponent} 134 - onEndReached={() => feeds.loadMore()} 135 - extraData={feeds.isLoading} 136 - /> 137 - </CenteredView> 138 - ) 139 - }), 140 - ) 141 - 142 - const styles = StyleSheet.create({ 143 - container: { 144 - flex: 1, 145 - }, 146 - contentContainer: { 147 - paddingBottom: 100, 148 - }, 149 - containerDesktop: { 150 - borderLeftWidth: 1, 151 - borderRightWidth: 1, 152 - }, 153 - empty: { 154 - paddingHorizontal: 16, 155 - paddingTop: 10, 156 - }, 157 - })
+245 -75
src/view/screens/Feeds.tsx
··· 1 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' 2 + import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' 3 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 + import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 5 + import {AtUri} from '@atproto/api' 5 6 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 6 - import {FlatList} from 'view/com/util/Views' 7 7 import {ViewHeader} from 'view/com/util/ViewHeader' 8 - import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 9 8 import {FAB} from 'view/com/util/fab/FAB' 10 9 import {Link} from 'view/com/util/Link' 11 10 import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' 12 11 import {observer} from 'mobx-react-lite' 13 - import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' 14 - import {MultiFeed} from 'view/com/posts/MultiFeed' 15 12 import {usePalette} from 'lib/hooks/usePalette' 16 - import {useTimer} from 'lib/hooks/useTimer' 17 13 import {useStores} from 'state/index' 18 14 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 19 - import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 20 15 import {ComposeIcon2, CogIcon} from 'lib/icons' 21 16 import {s} from 'lib/styles' 22 - 23 - const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds 24 - const MOBILE_HEADER_OFFSET = 40 17 + import {SearchInput} from 'view/com/util/forms/SearchInput' 18 + import {UserAvatar} from 'view/com/util/UserAvatar' 19 + import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 20 + import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 21 + import debounce from 'lodash.debounce' 22 + import {Text} from 'view/com/util/text/Text' 23 + import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' 24 + import {FlatList} from 'view/com/util/Views' 25 + import {useFocusEffect} from '@react-navigation/native' 26 + import {CustomFeed} from 'view/com/feeds/CustomFeed' 25 27 26 28 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> 27 29 export const FeedsScreen = withAuthRequired( 28 30 observer<Props>(function FeedsScreenImpl({}: Props) { 29 31 const pal = usePalette('default') 30 32 const store = useStores() 31 - const {isMobile} = useWebMediaQueries() 32 - const flatListRef = React.useRef<FlatList>(null) 33 - const multifeed = React.useMemo<PostsMultiFeedModel>( 34 - () => new PostsMultiFeedModel(store), 35 - [store], 33 + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() 34 + const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store]) 35 + const [query, setQuery] = React.useState<string>('') 36 + const debouncedSearchFeeds = React.useMemo( 37 + () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms 38 + [myFeeds], 36 39 ) 37 - const [onMainScroll, isScrolledDown, resetMainScroll] = 38 - useOnMainScroll(store) 39 - const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) 40 - const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { 41 - setLoadPromptVisible(true) 42 - }) 43 - 44 - const onSoftReset = React.useCallback(() => { 45 - flatListRef.current?.scrollToOffset({offset: 0}) 46 - multifeed.loadLatest() 47 - resetPromptTimer() 48 - setLoadPromptVisible(false) 49 - resetMainScroll() 50 - }, [ 51 - flatListRef, 52 - resetMainScroll, 53 - multifeed, 54 - resetPromptTimer, 55 - setLoadPromptVisible, 56 - ]) 57 40 58 41 useFocusEffect( 59 42 React.useCallback(() => { 60 - const softResetSub = store.onScreenSoftReset(onSoftReset) 61 - const multifeedCleanup = multifeed.registerListeners() 62 - const cleanup = () => { 63 - softResetSub.remove() 64 - multifeedCleanup() 65 - } 66 - 67 43 store.shell.setMinimalShellMode(false) 68 - return cleanup 69 - }, [store, multifeed, onSoftReset]), 44 + myFeeds.setup() 45 + }, [store.shell, myFeeds]), 70 46 ) 71 47 72 - React.useEffect(() => { 73 - if ( 74 - isEqual( 75 - multifeed.feedInfos.map(f => f.uri), 76 - store.me.savedFeeds.all.map(f => f.uri), 77 - ) 78 - ) { 79 - // no changes 80 - return 81 - } 82 - multifeed.refresh() 83 - }, [multifeed, store.me.savedFeeds.all]) 84 - 85 48 const onPressCompose = React.useCallback(() => { 86 49 store.shell.openComposer({}) 87 50 }, [store]) 51 + const onChangeQuery = React.useCallback( 52 + (text: string) => { 53 + setQuery(text) 54 + if (text.length > 1) { 55 + debouncedSearchFeeds(text) 56 + } else { 57 + myFeeds.discovery.refresh() 58 + } 59 + }, 60 + [debouncedSearchFeeds, myFeeds.discovery], 61 + ) 62 + const onPressCancelSearch = React.useCallback(() => { 63 + setQuery('') 64 + myFeeds.discovery.refresh() 65 + }, [myFeeds]) 66 + const onSubmitQuery = React.useCallback(() => { 67 + debouncedSearchFeeds(query) 68 + debouncedSearchFeeds.flush() 69 + }, [debouncedSearchFeeds, query]) 88 70 89 71 const renderHeaderBtn = React.useCallback(() => { 90 72 return ( ··· 99 81 ) 100 82 }, [pal]) 101 83 84 + const onRefresh = React.useCallback(() => { 85 + myFeeds.refresh() 86 + }, [myFeeds]) 87 + 88 + const renderItem = React.useCallback( 89 + ({item}: {item: MyFeedsItem}) => { 90 + if (item.type === 'discover-feeds-loading') { 91 + return <FeedFeedLoadingPlaceholder /> 92 + } else if (item.type === 'spinner') { 93 + return ( 94 + <View style={s.p10}> 95 + <ActivityIndicator /> 96 + </View> 97 + ) 98 + } else if (item.type === 'error') { 99 + return <ErrorMessage message={item.error} /> 100 + } else if (item.type === 'saved-feeds-header') { 101 + if (!isMobile) { 102 + return ( 103 + <View 104 + style={[ 105 + pal.view, 106 + styles.header, 107 + pal.border, 108 + { 109 + borderBottomWidth: 1, 110 + }, 111 + ]}> 112 + <Text type="title-lg" style={[pal.text, s.bold]}> 113 + My Feeds 114 + </Text> 115 + <Link href="/settings/saved-feeds"> 116 + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> 117 + </Link> 118 + </View> 119 + ) 120 + } 121 + return <View /> 122 + } else if (item.type === 'saved-feed') { 123 + return ( 124 + <SavedFeed 125 + uri={item.feed.uri} 126 + avatar={item.feed.data.avatar} 127 + displayName={item.feed.displayName} 128 + /> 129 + ) 130 + } else if (item.type === 'discover-feeds-header') { 131 + return ( 132 + <> 133 + <View 134 + style={[ 135 + pal.view, 136 + styles.header, 137 + { 138 + marginTop: 16, 139 + paddingLeft: isMobile ? 12 : undefined, 140 + paddingRight: 10, 141 + paddingBottom: isMobile ? 6 : undefined, 142 + }, 143 + ]}> 144 + <Text type="title-lg" style={[pal.text, s.bold]}> 145 + Discover new feeds 146 + </Text> 147 + {!isMobile && ( 148 + <SearchInput 149 + query={query} 150 + onChangeQuery={onChangeQuery} 151 + onPressCancelSearch={onPressCancelSearch} 152 + onSubmitQuery={onSubmitQuery} 153 + style={{flex: 1, maxWidth: 250}} 154 + /> 155 + )} 156 + </View> 157 + {isMobile && ( 158 + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> 159 + <SearchInput 160 + query={query} 161 + onChangeQuery={onChangeQuery} 162 + onPressCancelSearch={onPressCancelSearch} 163 + onSubmitQuery={onSubmitQuery} 164 + /> 165 + </View> 166 + )} 167 + </> 168 + ) 169 + } else if (item.type === 'discover-feed') { 170 + return ( 171 + <CustomFeed 172 + item={item.feed} 173 + showSaveBtn 174 + showDescription 175 + showLikes 176 + /> 177 + ) 178 + } else if (item.type === 'discover-feeds-no-results') { 179 + return ( 180 + <View 181 + style={{ 182 + paddingHorizontal: 16, 183 + paddingTop: 10, 184 + paddingBottom: '150%', 185 + }}> 186 + <Text type="lg" style={pal.textLight}> 187 + No results found for "{query}" 188 + </Text> 189 + </View> 190 + ) 191 + } 192 + return null 193 + }, 194 + [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], 195 + ) 196 + 102 197 return ( 103 198 <View style={[pal.view, styles.container]}> 104 - <MultiFeed 105 - scrollElRef={flatListRef} 106 - multifeed={multifeed} 107 - onScroll={onMainScroll} 108 - scrollEventThrottle={100} 109 - headerOffset={isMobile ? MOBILE_HEADER_OFFSET : undefined} 110 - /> 111 199 {isMobile && ( 112 200 <ViewHeader 113 - title="My Feeds" 201 + title="Feeds" 114 202 canGoBack={false} 115 - hideOnScroll 116 203 renderButton={renderHeaderBtn} 204 + showBorder 117 205 /> 118 206 )} 119 - {isScrolledDown || loadPromptVisible ? ( 120 - <LoadLatestBtn 121 - onPress={onSoftReset} 122 - label="Load latest posts" 123 - showIndicator={loadPromptVisible} 124 - /> 125 - ) : null} 207 + 208 + <FlatList 209 + style={[!isTabletOrDesktop && s.flex1, styles.list]} 210 + data={myFeeds.items} 211 + keyExtractor={item => item._reactKey} 212 + contentContainerStyle={styles.contentContainer} 213 + refreshControl={ 214 + <RefreshControl 215 + refreshing={myFeeds.isRefreshing} 216 + onRefresh={onRefresh} 217 + tintColor={pal.colors.text} 218 + titleColor={pal.colors.text} 219 + /> 220 + } 221 + renderItem={renderItem} 222 + initialNumToRender={10} 223 + onEndReached={() => myFeeds.loadMore()} 224 + extraData={myFeeds.isLoading} 225 + // @ts-ignore our .web version only -prf 226 + desktopFixedHeight 227 + /> 126 228 <FAB 127 229 testID="composeFAB" 128 230 onPress={onPressCompose} ··· 136 238 }), 137 239 ) 138 240 241 + function SavedFeed({ 242 + uri, 243 + avatar, 244 + displayName, 245 + }: { 246 + uri: string 247 + avatar: string | undefined 248 + displayName: string 249 + }) { 250 + const pal = usePalette('default') 251 + const urip = new AtUri(uri) 252 + const href = `/profile/${urip.hostname}/feed/${urip.rkey}` 253 + const {isMobile} = useWebMediaQueries() 254 + return ( 255 + <Link 256 + testID={`saved-feed-${displayName}`} 257 + href={href} 258 + style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} 259 + hoverStyle={pal.viewLight} 260 + accessibilityLabel={displayName} 261 + accessibilityHint="" 262 + asAnchor 263 + anchorNoUnderline> 264 + <UserAvatar type="algo" size={28} avatar={avatar} /> 265 + <Text 266 + type={isMobile ? 'lg' : 'lg-medium'} 267 + style={[pal.text, s.flex1]} 268 + numberOfLines={1}> 269 + {displayName} 270 + </Text> 271 + {isMobile && ( 272 + <FontAwesomeIcon 273 + icon="chevron-right" 274 + size={14} 275 + style={pal.textLight as FontAwesomeIconStyle} 276 + /> 277 + )} 278 + </Link> 279 + ) 280 + } 281 + 139 282 const styles = StyleSheet.create({ 140 283 container: { 141 284 flex: 1, 285 + }, 286 + list: { 287 + height: '100%', 288 + }, 289 + contentContainer: { 290 + paddingBottom: 100, 291 + }, 292 + 293 + header: { 294 + flexDirection: 'row', 295 + alignItems: 'center', 296 + justifyContent: 'space-between', 297 + gap: 16, 298 + paddingHorizontal: 16, 299 + paddingVertical: 12, 300 + }, 301 + 302 + savedFeed: { 303 + flexDirection: 'row', 304 + alignItems: 'center', 305 + paddingHorizontal: 16, 306 + paddingVertical: 14, 307 + gap: 12, 308 + borderBottomWidth: 1, 309 + }, 310 + savedFeedMobile: { 311 + paddingVertical: 10, 142 312 }, 143 313 })
+77 -10
src/view/screens/Home.tsx
··· 1 1 import React from 'react' 2 2 import {FlatList, View} from 'react-native' 3 3 import {useFocusEffect, useIsFocused} from '@react-navigation/native' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 4 6 import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' 5 7 import {observer} from 'mobx-react-lite' 6 8 import useAppState from 'react-native-appstate-hook' ··· 8 10 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' 9 11 import {PostsFeedModel} from 'state/models/feeds/posts' 10 12 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 13 + import {TextLink} from 'view/com/util/Link' 11 14 import {Feed} from '../com/posts/Feed' 12 15 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' 13 16 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' ··· 16 19 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 17 20 import {FAB} from '../com/util/fab/FAB' 18 21 import {useStores} from 'state/index' 19 - import {s} from 'lib/styles' 22 + import {usePalette} from 'lib/hooks/usePalette' 23 + import {s, colors} from 'lib/styles' 20 24 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 21 25 import {useAnalytics} from 'lib/analytics/analytics' 22 26 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 23 27 import {ComposeIcon2} from 'lib/icons' 24 28 25 29 const HEADER_OFFSET_MOBILE = 78 26 - const HEADER_OFFSET_DESKTOP = 50 30 + const HEADER_OFFSET_TABLET = 50 31 + const HEADER_OFFSET_DESKTOP = 0 27 32 const POLL_FREQ = 30e3 // 30sec 28 33 29 34 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> ··· 154 159 renderEmptyState?: () => JSX.Element 155 160 }) { 156 161 const store = useStores() 157 - const {isMobile} = useWebMediaQueries() 162 + const pal = usePalette('default') 163 + const {isMobile, isTablet, isDesktop} = useWebMediaQueries() 158 164 const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) 159 165 const {screen, track} = useAnalytics() 160 166 const [headerOffset, setHeaderOffset] = React.useState( 161 - isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, 167 + isMobile 168 + ? HEADER_OFFSET_MOBILE 169 + : isTablet 170 + ? HEADER_OFFSET_TABLET 171 + : HEADER_OFFSET_DESKTOP, 162 172 ) 163 173 const scrollElRef = React.useRef<FlatList>(null) 164 174 const {appState} = useAppState({ 165 175 onForeground: () => doPoll(true), 166 176 }) 167 177 const isScreenFocused = useIsFocused() 178 + const hasNew = feed.hasNewLatest && !feed.isRefreshing 168 179 169 180 React.useEffect(() => { 170 181 // called on first load ··· 205 216 206 217 // listens for resize events 207 218 React.useEffect(() => { 208 - setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP) 209 - }, [isMobile]) 219 + setHeaderOffset( 220 + isMobile 221 + ? HEADER_OFFSET_MOBILE 222 + : isTablet 223 + ? HEADER_OFFSET_TABLET 224 + : HEADER_OFFSET_DESKTOP, 225 + ) 226 + }, [isMobile, isTablet]) 210 227 211 228 // fires when page within screen is activated/deactivated 212 229 // - check for latest ··· 222 239 screen('Feed') 223 240 store.log.debug('HomeScreen: Updating feed') 224 241 feed.checkForLatest() 225 - if (feed.hasContent) { 226 - feed.update() 227 - } 228 242 229 243 return () => { 230 244 clearInterval(pollInterval) ··· 247 261 feed.refresh() 248 262 }, [feed, scrollToTop]) 249 263 250 - const hasNew = feed.hasNewLatest && !feed.isRefreshing 264 + const ListHeaderComponent = React.useCallback(() => { 265 + if (isDesktop) { 266 + return ( 267 + <View 268 + style={[ 269 + pal.view, 270 + { 271 + flexDirection: 'row', 272 + alignItems: 'center', 273 + justifyContent: 'space-between', 274 + paddingHorizontal: 18, 275 + paddingVertical: 12, 276 + }, 277 + ]}> 278 + <TextLink 279 + type="title-lg" 280 + href="/" 281 + style={[pal.text, {fontWeight: 'bold'}]} 282 + text={ 283 + <> 284 + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} 285 + {hasNew && ( 286 + <View 287 + style={{ 288 + top: -8, 289 + backgroundColor: colors.blue3, 290 + width: 8, 291 + height: 8, 292 + borderRadius: 4, 293 + }} 294 + /> 295 + )} 296 + </> 297 + } 298 + onPress={() => store.emitScreenSoftReset()} 299 + /> 300 + <TextLink 301 + type="title-lg" 302 + href="/settings/home-feed" 303 + style={{fontWeight: 'bold'}} 304 + text={ 305 + <FontAwesomeIcon 306 + icon="sliders" 307 + style={pal.textLight as FontAwesomeIconStyle} 308 + /> 309 + } 310 + /> 311 + </View> 312 + ) 313 + } 314 + return <></> 315 + }, [isDesktop, pal, store, hasNew]) 316 + 251 317 return ( 252 318 <View testID={testID} style={s.h100pct}> 253 319 <Feed ··· 259 325 onScroll={onMainScroll} 260 326 scrollEventThrottle={100} 261 327 renderEmptyState={renderEmptyState} 328 + ListHeaderComponent={ListHeaderComponent} 262 329 headerOffset={headerOffset} 263 330 /> 264 331 {(isScrolledDown || hasNew) && (
+53 -4
src/view/screens/Notifications.tsx
··· 9 9 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 10 10 import {ViewHeader} from '../com/util/ViewHeader' 11 11 import {Feed} from '../com/notifications/Feed' 12 + import {TextLink} from 'view/com/util/Link' 12 13 import {InvitedUsers} from '../com/notifications/InvitedUsers' 13 14 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 14 15 import {useStores} from 'state/index' 15 16 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 16 17 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' 17 - import {s} from 'lib/styles' 18 + import {usePalette} from 'lib/hooks/usePalette' 19 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 20 + import {s, colors} from 'lib/styles' 18 21 import {useAnalytics} from 'lib/analytics/analytics' 19 22 import {isWeb} from 'platform/detection' 20 23 ··· 29 32 useOnMainScroll(store) 30 33 const scrollElRef = React.useRef<FlatList>(null) 31 34 const {screen} = useAnalytics() 35 + const pal = usePalette('default') 36 + const {isDesktop} = useWebMediaQueries() 37 + 38 + const hasNew = 39 + store.me.notifications.hasNewLatest && 40 + !store.me.notifications.isRefreshing 32 41 33 42 // event handlers 34 43 // = ··· 88 97 ), 89 98 ) 90 99 91 - const hasNew = 92 - store.me.notifications.hasNewLatest && 93 - !store.me.notifications.isRefreshing 100 + const ListHeaderComponent = React.useCallback(() => { 101 + if (isDesktop) { 102 + return ( 103 + <View 104 + style={[ 105 + pal.view, 106 + { 107 + flexDirection: 'row', 108 + alignItems: 'center', 109 + justifyContent: 'space-between', 110 + paddingHorizontal: 18, 111 + paddingVertical: 12, 112 + }, 113 + ]}> 114 + <TextLink 115 + type="title-lg" 116 + href="/notifications" 117 + style={[pal.text, {fontWeight: 'bold'}]} 118 + text={ 119 + <> 120 + Notifications{' '} 121 + {hasNew && ( 122 + <View 123 + style={{ 124 + top: -8, 125 + backgroundColor: colors.blue3, 126 + width: 8, 127 + height: 8, 128 + borderRadius: 4, 129 + }} 130 + /> 131 + )} 132 + </> 133 + } 134 + onPress={() => store.emitScreenSoftReset()} 135 + /> 136 + </View> 137 + ) 138 + } 139 + return <></> 140 + }, [isDesktop, pal, store, hasNew]) 141 + 94 142 return ( 95 143 <View testID="notificationsScreen" style={s.hContentRegion}> 96 144 <ViewHeader title="Notifications" canGoBack={false} /> ··· 100 148 onPressTryAgain={onPressTryAgain} 101 149 onScroll={onMainScroll} 102 150 scrollElRef={scrollElRef} 151 + ListHeaderComponent={ListHeaderComponent} 103 152 /> 104 153 {(isScrolledDown || hasNew) && ( 105 154 <LoadLatestBtn
+55 -11
src/view/screens/PreferencesHomeFeed.tsx
··· 19 19 const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) 20 20 21 21 return ( 22 - <View style={[s.mt10, !enabled && styles.dimmed]}> 23 - <Text type="xs" style={pal.text}> 24 - {value === 0 25 - ? `Show all replies` 26 - : `Show replies with at least ${value} ${ 27 - value > 1 ? `likes` : `like` 28 - }`} 29 - </Text> 22 + <View style={[!enabled && styles.dimmed]}> 30 23 <Slider 31 24 value={value} 32 25 onValueChange={(v: number | number[]) => { ··· 40 33 disabled={!enabled} 41 34 thumbTintColor={colors.blue3} 42 35 /> 36 + <Text type="xs" style={pal.text}> 37 + {value === 0 38 + ? `Show all replies` 39 + : `Show replies with at least ${value} ${ 40 + value > 1 ? `likes` : `like` 41 + }`} 42 + </Text> 43 43 </View> 44 44 ) 45 45 } ··· 79 79 Show Replies 80 80 </Text> 81 81 <Text style={[pal.text, s.pb10]}> 82 - Adjust the number of likes a reply must have to be shown in your 83 - feed. 82 + Set this setting to "No" to hide all replies from your feed. 84 83 </Text> 85 84 <ToggleButton 86 85 type="default-light" ··· 88 87 isSelected={store.preferences.homeFeedRepliesEnabled} 89 88 onPress={store.preferences.toggleHomeFeedRepliesEnabled} 90 89 /> 91 - 90 + </View> 91 + <View 92 + style={[ 93 + pal.viewLight, 94 + styles.card, 95 + !store.preferences.homeFeedRepliesEnabled && styles.dimmed, 96 + ]}> 97 + <Text type="title-sm" style={[pal.text, s.pb5]}> 98 + Reply Filters 99 + </Text> 100 + <Text style={[pal.text, s.pb10]}> 101 + Enable this setting to only see replies between people you follow. 102 + </Text> 103 + <ToggleButton 104 + type="default-light" 105 + label="Followed users only" 106 + isSelected={ 107 + store.preferences.homeFeedRepliesByFollowedOnlyEnabled 108 + } 109 + onPress={ 110 + store.preferences.homeFeedRepliesEnabled 111 + ? store.preferences.toggleHomeFeedRepliesByFollowedOnlyEnabled 112 + : undefined 113 + } 114 + style={[s.mb10]} 115 + /> 116 + <Text style={[pal.text]}> 117 + Adjust the number of likes a reply must have to be shown in your 118 + feed. 119 + </Text> 92 120 <RepliesThresholdInput 93 121 enabled={store.preferences.homeFeedRepliesEnabled} 94 122 /> ··· 122 150 label={store.preferences.homeFeedQuotePostsEnabled ? 'Yes' : 'No'} 123 151 isSelected={store.preferences.homeFeedQuotePostsEnabled} 124 152 onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} 153 + /> 154 + </View> 155 + 156 + <View style={[pal.viewLight, styles.card]}> 157 + <Text type="title-sm" style={[pal.text, s.pb5]}> 158 + Show Posts from My Feeds (Experimental) 159 + </Text> 160 + <Text style={[pal.text, s.pb10]}> 161 + Set this setting to "Yes" to show samples of your saved feeds in 162 + your following feed. 163 + </Text> 164 + <ToggleButton 165 + type="default-light" 166 + label={store.preferences.homeFeedMergeFeedEnabled ? 'Yes' : 'No'} 167 + isSelected={store.preferences.homeFeedMergeFeedEnabled} 168 + onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} 125 169 /> 126 170 </View> 127 171 </View>
+1 -3
src/view/screens/Profile.tsx
··· 69 69 let aborted = false 70 70 store.shell.setMinimalShellMode(false) 71 71 const feedCleanup = uiState.feed.registerListeners() 72 - if (hasSetup) { 73 - uiState.update() 74 - } else { 72 + if (!hasSetup) { 75 73 uiState.setup().then(() => { 76 74 if (aborted) { 77 75 return
+1 -1
src/view/screens/SavedFeeds.tsx
··· 70 70 return ( 71 71 <> 72 72 <View style={[styles.footerLinks, pal.border]}> 73 - <Link style={styles.footerLink} href="/search/feeds"> 73 + <Link style={styles.footerLink} href="/feeds"> 74 74 <FontAwesomeIcon 75 75 icon="search" 76 76 size={18}
+4 -7
src/view/screens/Settings.tsx
··· 40 40 import {useAnalytics} from 'lib/analytics/analytics' 41 41 import {NavigationProp} from 'lib/routes/types' 42 42 import {pluralize} from 'lib/strings/helpers' 43 - import {HandIcon} from 'lib/icons' 43 + import {HandIcon, HashtagIcon} from 'lib/icons' 44 44 import {formatCount} from 'view/com/util/numeric/format' 45 45 import Clipboard from '@react-native-clipboard/clipboard' 46 46 import {reset as resetNavigation} from '../../Navigation' ··· 423 423 <TouchableOpacity 424 424 testID="savedFeedsBtn" 425 425 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 426 - accessibilityHint="Saved Feeds" 426 + accessibilityHint="My Saved Feeds" 427 427 accessibilityLabel="Opens screen with all saved feeds" 428 428 onPress={onPressSavedFeeds}> 429 429 <View style={[styles.iconContainer, pal.btn]}> 430 - <FontAwesomeIcon 431 - icon="satellite-dish" 432 - style={pal.text as FontAwesomeIconStyle} 433 - /> 430 + <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> 434 431 </View> 435 432 <Text type="lg" style={pal.text}> 436 - Saved Feeds 433 + My Saved Feeds 437 434 </Text> 438 435 </TouchableOpacity> 439 436 <TouchableOpacity
+7 -8
src/view/shell/Drawer.tsx
··· 28 28 MagnifyingGlassIcon2, 29 29 MagnifyingGlassIcon2Solid, 30 30 UserIconSolid, 31 - SatelliteDishIcon, 32 - SatelliteDishIconSolid, 31 + HashtagIcon, 33 32 HandIcon, 34 33 } from 'lib/icons' 35 34 import {UserAvatar} from 'view/com/util/UserAvatar' ··· 258 257 <MenuItem 259 258 icon={ 260 259 isAtFeeds ? ( 261 - <SatelliteDishIconSolid 262 - strokeWidth={1.5} 260 + <HashtagIcon 261 + strokeWidth={3} 263 262 style={pal.text as FontAwesomeIconStyle} 264 263 size={24} 265 264 /> 266 265 ) : ( 267 - <SatelliteDishIcon 268 - strokeWidth={1.5} 266 + <HashtagIcon 267 + strokeWidth={2} 269 268 style={pal.text as FontAwesomeIconStyle} 270 269 size={24} 271 270 /> 272 271 ) 273 272 } 274 - label="My Feeds" 275 - accessibilityLabel="My Feeds" 273 + label="Feeds" 274 + accessibilityLabel="Feeds" 276 275 accessibilityHint="" 277 276 onPress={onPressMyFeeds} 278 277 />
+9 -10
src/view/shell/bottom-bar/BottomBar.tsx
··· 18 18 HomeIconSolid, 19 19 MagnifyingGlassIcon2, 20 20 MagnifyingGlassIcon2Solid, 21 - SatelliteDishIcon, 22 - SatelliteDishIconSolid, 21 + HashtagIcon, 23 22 BellIcon, 24 23 BellIconSolid, 25 24 } from 'lib/icons' ··· 134 133 testID="bottomBarFeedsBtn" 135 134 icon={ 136 135 isAtFeeds ? ( 137 - <SatelliteDishIconSolid 138 - size={25} 139 - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 140 - strokeWidth={1.8} 136 + <HashtagIcon 137 + size={24} 138 + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 139 + strokeWidth={4} 141 140 /> 142 141 ) : ( 143 - <SatelliteDishIcon 144 - size={25} 145 - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 146 - strokeWidth={1.8} 142 + <HashtagIcon 143 + size={24} 144 + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 145 + strokeWidth={2.25} 147 146 /> 148 147 ) 149 148 }
+3
src/view/shell/bottom-bar/BottomBarStyles.tsx
··· 49 49 homeIcon: { 50 50 top: 0, 51 51 }, 52 + feedsIcon: { 53 + top: -2, 54 + }, 52 55 searchIcon: { 53 56 top: -2, 54 57 },
+5 -7
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 15 15 HomeIconSolid, 16 16 MagnifyingGlassIcon2, 17 17 MagnifyingGlassIcon2Solid, 18 - SatelliteDishIcon, 19 - SatelliteDishIconSolid, 18 + HashtagIcon, 20 19 UserIcon, 21 20 UserIconSolid, 22 21 } from 'lib/icons' ··· 68 67 </NavItem> 69 68 <NavItem routeName="Feeds" href="/feeds"> 70 69 {({isActive}) => { 71 - const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon 72 70 return ( 73 - <Icon 74 - size={25} 75 - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 76 - strokeWidth={1.8} 71 + <HashtagIcon 72 + size={22} 73 + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 74 + strokeWidth={isActive ? 4 : 2.5} 77 75 /> 78 76 ) 79 77 }}
+92
src/view/shell/desktop/Feeds.tsx
··· 1 + import React from 'react' 2 + import {View, StyleSheet} from 'react-native' 3 + import {useNavigationState} from '@react-navigation/native' 4 + import {AtUri} from '@atproto/api' 5 + import {observer} from 'mobx-react-lite' 6 + import {useStores} from 'state/index' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {TextLink} from 'view/com/util/Link' 9 + import {getCurrentRoute} from 'lib/routes/helpers' 10 + 11 + export const DesktopFeeds = observer(function DesktopFeeds() { 12 + const store = useStores() 13 + const pal = usePalette('default') 14 + 15 + const route = useNavigationState(state => { 16 + if (!state) { 17 + return {name: 'Home'} 18 + } 19 + return getCurrentRoute(state) 20 + }) 21 + 22 + return ( 23 + <View style={[styles.container, pal.view, pal.border]}> 24 + <FeedItem href="/" title="Following" current={route.name === 'Home'} /> 25 + {store.me.savedFeeds.pinned.map(feed => { 26 + try { 27 + const {hostname, rkey} = new AtUri(feed.uri) 28 + const href = `/profile/${hostname}/feed/${rkey}` 29 + const params = route.params as Record<string, string> 30 + return ( 31 + <FeedItem 32 + key={feed.uri} 33 + href={href} 34 + title={feed.displayName} 35 + current={ 36 + route.name === 'CustomFeed' && 37 + params.name === hostname && 38 + params.rkey === rkey 39 + } 40 + /> 41 + ) 42 + } catch { 43 + return null 44 + } 45 + })} 46 + <View style={{paddingTop: 8, paddingBottom: 6}}> 47 + <TextLink 48 + type="lg" 49 + href="/feeds" 50 + text="More feeds" 51 + style={[pal.link]} 52 + /> 53 + </View> 54 + </View> 55 + ) 56 + }) 57 + 58 + function FeedItem({ 59 + title, 60 + href, 61 + current, 62 + }: { 63 + title: string 64 + href: string 65 + current: boolean 66 + }) { 67 + const pal = usePalette('default') 68 + return ( 69 + <View style={{paddingVertical: 6}}> 70 + <TextLink 71 + type="xl" 72 + href={href} 73 + text={title} 74 + style={[ 75 + current ? pal.text : pal.textLight, 76 + {letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'}, 77 + ]} 78 + /> 79 + </View> 80 + ) 81 + } 82 + 83 + const styles = StyleSheet.create({ 84 + container: { 85 + position: 'relative', 86 + width: 300, 87 + paddingHorizontal: 12, 88 + borderTopWidth: 1, 89 + borderBottomWidth: 1, 90 + paddingVertical: 18, 91 + }, 92 + })
+6 -7
src/view/shell/desktop/LeftNav.tsx
··· 32 32 CogIconSolid, 33 33 ComposeIcon2, 34 34 HandIcon, 35 - SatelliteDishIcon, 36 - SatelliteDishIconSolid, 35 + HashtagIcon, 37 36 } from 'lib/icons' 38 37 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' 39 38 import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' ··· 272 271 <NavItem 273 272 href="/feeds" 274 273 icon={ 275 - <SatelliteDishIcon 276 - strokeWidth={1.75} 274 + <HashtagIcon 275 + strokeWidth={2.25} 277 276 style={pal.text as FontAwesomeIconStyle} 278 277 size={isDesktop ? 24 : 28} 279 278 /> 280 279 } 281 280 iconFilled={ 282 - <SatelliteDishIconSolid 283 - strokeWidth={1.75} 281 + <HashtagIcon 282 + strokeWidth={2.5} 284 283 style={pal.text as FontAwesomeIconStyle} 285 284 size={isDesktop ? 24 : 28} 286 285 /> 287 286 } 288 - label="My Feeds" 287 + label="Feeds" 289 288 /> 290 289 <NavItem 291 290 href="/notifications"
+3 -2
src/view/shell/desktop/RightNav.tsx
··· 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 6 import {DesktopSearch} from './Search' 7 + import {DesktopFeeds} from './Feeds' 7 8 import {Text} from 'view/com/util/text/Text' 8 9 import {TextLink} from 'view/com/util/Link' 9 10 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' ··· 26 27 return ( 27 28 <View style={[styles.rightNav, pal.view]}> 28 29 {store.session.hasSession && <DesktopSearch />} 30 + {store.session.hasSession && <DesktopFeeds />} 29 31 <View style={styles.message}> 30 32 {store.session.isSandbox ? ( 31 33 <View style={[palError.view, styles.messageLine, s.p10]}> ··· 126 128 }, 127 129 128 130 message: { 129 - marginTop: 20, 131 + paddingVertical: 18, 130 132 paddingHorizontal: 10, 131 133 }, 132 134 messageLine: { ··· 134 136 }, 135 137 136 138 inviteCodes: { 137 - marginTop: 12, 138 139 borderTopWidth: 1, 139 140 paddingHorizontal: 16, 140 141 paddingVertical: 12,
+1
src/view/shell/desktop/Search.tsx
··· 113 113 container: { 114 114 position: 'relative', 115 115 width: 300, 116 + paddingBottom: 18, 116 117 }, 117 118 search: { 118 119 paddingHorizontal: 16,
+16 -4
yarn.lock
··· 6418 6418 dependencies: 6419 6419 "@types/lodash" "*" 6420 6420 6421 + "@types/lodash.random@^3.2.7": 6422 + version "3.2.7" 6423 + resolved "https://registry.yarnpkg.com/@types/lodash.random/-/lodash.random-3.2.7.tgz#3100a1b7956ce86ab5adcce2e7b305412b98e3bf" 6424 + integrity sha512-gFKkVgWYi1q7RFJ+QNTzaRprdhVIZLpZd6C3MTNehKcujMn9SyFUqf2fTBOmvIYXqNk0RpwfbdOwHf0GnEQB0g== 6425 + dependencies: 6426 + "@types/lodash" "*" 6427 + 6421 6428 "@types/lodash.samplesize@^4.2.7": 6422 6429 version "4.2.7" 6423 6430 resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f" ··· 13886 13893 resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" 13887 13894 integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== 13888 13895 13896 + lodash.random@^3.2.0: 13897 + version "3.2.0" 13898 + resolved "https://registry.yarnpkg.com/lodash.random/-/lodash.random-3.2.0.tgz#96e24e763333199130d2c9e2fd57f91703cc262d" 13899 + integrity sha512-A6Vn7teN0+qSnhOsE8yx2bGowCS1G7D9e5abq8VhwOP98YHS/KrGMf43yYxA05lvcvloT+W9Z2ffkSajFTcPUA== 13900 + 13889 13901 lodash.samplesize@^4.2.0: 13890 13902 version "4.2.0" 13891 13903 resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9" ··· 16855 16867 loose-envify "^1.1.0" 16856 16868 scheduler "^0.23.0" 16857 16869 16858 - react-error-overlay@^6.0.11: 16859 - version "6.0.11" 16860 - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" 16861 - integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== 16870 + react-error-overlay@6.0.9, react-error-overlay@^6.0.11: 16871 + version "6.0.9" 16872 + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" 16873 + integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== 16862 16874 16863 16875 react-freeze@^1.0.0: 16864 16876 version "1.0.3"