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>
zio/stable
Paul Frazee 2023-09-18 11:44:29 -07:00 committed by GitHub
parent 3118e3e933
commit ea885339cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1884 additions and 1497 deletions

View File

@ -102,6 +102,7 @@
"lodash.isequal": "^4.5.0",
"lodash.omit": "^4.5.0",
"lodash.once": "^4.1.1",
"lodash.random": "^3.2.0",
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
@ -168,6 +169,7 @@
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.omit": "^4.5.7",
"@types/lodash.once": "^4.1.7",
"@types/lodash.random": "^3.2.7",
"@types/lodash.samplesize": "^4.2.7",
"@types/lodash.set": "^4.3.7",
"@types/lodash.shuffle": "^4.2.7",

View File

@ -40,7 +40,6 @@ import {FeedsScreen} from './view/screens/Feeds'
import {NotificationsScreen} from './view/screens/Notifications'
import {ModerationScreen} from './view/screens/Moderation'
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
import {NotFoundScreen} from './view/screens/NotFound'
import {SettingsScreen} from './view/screens/Settings'
import {ProfileScreen} from './view/screens/Profile'
@ -113,11 +112,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
component={ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="DiscoverFeeds"
component={DiscoverFeedsScreen}
options={{title: title('Discover Feeds')}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}

View File

@ -4,6 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {FeedSourceInfo} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@ -64,6 +65,11 @@ export class FeedViewPostsSlice {
)
}
get source(): FeedSourceInfo | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as FeedSourceInfo
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
@ -91,6 +97,23 @@ export class FeedViewPostsSlice {
}
}
}
isFollowingAllAuthors(userDid: string) {
const item = this.rootItem
if (item.post.author.did === userDid) {
return true
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
const parent = item.reply?.parent
if (parent?.author.did === userDid) {
return true
}
return (
parent?.author.viewer?.following && item.post.author.viewer?.following
)
}
return false
}
}
export class FeedTuner {
@ -222,20 +245,34 @@ export class FeedTuner {
return slices
}
static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
static thresholdRepliesOnly({
userDid,
minLikes,
followedOnly,
}: {
userDid: string
minLikes: number
followedOnly: boolean
}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => {
// remove any replies without at least repliesThreshold likes
// remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
if (slices[i].isFullThread || !slices[i].isReply) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
continue
}
const item = slices[i].rootItem
const item = slice.rootItem
const isRepost = Boolean(item.reason)
if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
if (isRepost) {
continue
}
if ((item.post.likeCount || 0) < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
}
}

View File

@ -0,0 +1,45 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetAuthorFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getAuthorFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getAuthorFeed({
...this.params,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}

View File

@ -0,0 +1,52 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetCustomFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
...this.params,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (res.data.feed.length > limit) {
res.data.feed = res.data.feed.slice(0, limit)
}
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}

View File

@ -0,0 +1,37 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getTimeline({
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}

View File

@ -0,0 +1,45 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public rootStore: RootStoreModel,
public params: GetActorLikes.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getActorLikes({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getActorLikes({
...this.params,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
}
}
return {
feed: [],
}
}
}

View File

@ -0,0 +1,236 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {RootStoreModel} from 'state/index'
import {timeout} from 'lib/async/timeout'
import {bundleAsync} from 'lib/async/bundle'
import {feedUriToHref} from 'lib/strings/url-helpers'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
following: MergeFeedSource_Following
customFeeds: MergeFeedSource_Custom[] = []
feedCursor = 0
itemCursor = 0
sampleCursor = 0
constructor(public rootStore: RootStoreModel) {
this.following = new MergeFeedSource_Following(this.rootStore)
}
reset() {
this.following = new MergeFeedSource_Following(this.rootStore)
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
this.feedCursor = 0
this.itemCursor = 0
this.sampleCursor = 0
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.rootStore.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
// we capture here to ensure the data has loaded
this._captureFeedsIfNeeded()
const promises = []
// always keep following topped up
if (this.following.numReady < limit) {
promises.push(this.following.fetchNext(30))
}
// pick the next feeds to sample from
const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3)
this.feedCursor += 3
if (this.feedCursor > this.customFeeds.length) {
this.feedCursor = 0
}
// top up the feeds
for (const feed of feeds) {
if (feed.numReady < 5) {
promises.push(feed.fetchNext(10))
}
}
// wait for requests (all capped at a fixed timeout)
await Promise.all(promises)
// assemble a response by sampling from feeds with content
const posts: AppBskyFeedDefs.FeedViewPost[] = []
while (posts.length < limit) {
let slice = this.sampleItem()
if (slice[0]) {
posts.push(slice[0])
} else {
break
}
}
return {
cursor: posts.length ? 'fake' : undefined,
feed: posts,
}
}
sampleItem() {
const i = this.itemCursor++
const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
const canSample = candidateFeeds.length > 0
const hasFollows = this.following.numReady > 0
// this condition establishes the frequency that custom feeds are woven into follows
const shouldSample =
i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0)
if (!canSample && !hasFollows) {
// no data available
return []
}
if (shouldSample || !hasFollows) {
// time to sample, or the user isnt following anybody
return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
}
// not time to sample
return this.following.take(1)
}
_captureFeedsIfNeeded() {
if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) {
return
}
if (this.customFeeds.length === 0) {
this.customFeeds = shuffle(
this.rootStore.me.savedFeeds.all.map(
feed =>
new MergeFeedSource_Custom(
this.rootStore,
feed.uri,
feed.displayName,
),
),
)
}
}
}
class MergeFeedSource {
sourceInfo: FeedSourceInfo | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public rootStore: RootStoreModel) {}
get numReady() {
return this.queue.length
}
get needsFetch() {
return this.hasMore && this.queue.length === 0
}
reset() {
this.cursor = undefined
this.queue = []
this.hasMore = true
}
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
return this.queue.splice(0, n)
}
async fetchNext(n: number) {
await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)])
}
_fetchNextInner = bundleAsync(async (n: number) => {
const res = await this._getFeed(this.cursor, n)
if (res.success) {
this.cursor = res.data.cursor
if (res.data.feed.length) {
this.queue = this.queue.concat(res.data.feed)
} else {
this.hasMore = false
}
} else {
this.hasMore = false
}
})
protected _getFeed(
_cursor: string | undefined,
_limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
throw new Error('Must be overridden')
}
}
class MergeFeedSource_Following extends MergeFeedSource {
async fetchNext(n: number) {
return this._fetchNextInner(n)
}
protected async _getFeed(
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.getTimeline({cursor, limit})
// filter out mutes pre-emptively to ensure better mixing
res.data.feed = res.data.feed.filter(
post => !post.post.author.viewer?.muted,
)
return res
}
}
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public rootStore: RootStoreModel,
public feedUri: string,
public feedDisplayName: string,
) {
super(rootStore)
this.sourceInfo = {
displayName: feedDisplayName,
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
}
protected async _getFeed(
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
})
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (limit && res.data.feed.length > limit) {
res.data.feed = res.data.feed.slice(0, limit)
}
// filter out older posts
res.data.feed = res.data.feed.filter(
post => new Date(post.post.indexedAt) > this.minDate,
)
// attach source info
for (const post of res.data.feed) {
post.__source = this.sourceInfo
}
return res
}
}

View File

@ -0,0 +1,17 @@
import {AppBskyFeedDefs} from '@atproto/api'
export interface FeedAPIResponse {
cursor?: string
feed: AppBskyFeedDefs.FeedViewPost[]
}
export interface FeedAPI {
reset(): void
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
}
export interface FeedSourceInfo {
uri: string
displayName: string
}

View File

@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
export function GridIcon({
style,
@ -884,45 +884,7 @@ export function HandIcon({
)
}
export function SatelliteDishIconSolid({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
width={size || 24}
height={size || 24}
viewBox="0 0 22 22"
style={style}
fill="none"
stroke="none">
<Path
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"
fill="currentColor"
/>
<Path
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"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Path
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"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<Circle cx="10" cy="12" r="2" fill="currentColor" />
</Svg>
)
}
export function SatelliteDishIcon({
export function HashtagIcon({
style,
size,
strokeWidth = 1.5,
@ -934,26 +896,16 @@ export function SatelliteDishIcon({
return (
<Svg
fill="none"
viewBox="0 0 22 22"
strokeWidth={strokeWidth}
stroke="currentColor"
viewBox="0 0 30 30"
strokeWidth={strokeWidth}
width={size}
height={size}
style={style}>
<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" />
<Path
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"
strokeLinecap="round"
/>
<Path
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"
strokeLinecap="round"
/>
<Path
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"
fill="currentColor"
stroke="none"
/>
<Path d="M2 10H28" strokeLinecap="round" />
<Path d="M2 20H28" strokeLinecap="round" />
<Path d="M11 3L9 27" strokeLinecap="round" />
<Path d="M21 3L19 27" strokeLinecap="round" />
</Svg>
)
}

View File

@ -9,7 +9,6 @@ export type CommonNavigatorParams = {
ModerationMuteLists: undefined
ModerationMutedAccounts: undefined
ModerationBlockedAccounts: undefined
DiscoverFeeds: undefined
Settings: undefined
Profile: {name: string; hideBackButton?: boolean}
ProfileFollowers: {name: string}

View File

@ -129,6 +129,15 @@ export function listUriToHref(url: string): string {
}
}
export function feedUriToHref(url: string): string {
try {
const {hostname, rkey} = new AtUri(url)
return `/profile/${hostname}/feed/${rkey}`
} catch {
return ''
}
}
export function getYoutubeVideoId(link: string): string | undefined {
let url
try {

View File

@ -4,7 +4,6 @@ export const router = new Router({
Home: '/',
Search: '/search',
Feeds: '/feeds',
DiscoverFeeds: '/search/feeds',
Notifications: '/notifications',
Settings: '/settings',
Moderation: '/moderation',

View File

@ -1,227 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '@atproto/api'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
import {CustomFeedModel} from './custom-feed'
import {PostsFeedModel} from './posts'
import {PostsFeedSliceModel} from './posts-slice'
import {makeProfileLink} from 'lib/routes/links'
const FEED_PAGE_SIZE = 10
const FEEDS_PAGE_SIZE = 3
export type MultiFeedItem =
| {
_reactKey: string
type: 'header'
}
| {
_reactKey: string
type: 'feed-header'
avatar: string | undefined
title: string
}
| {
_reactKey: string
type: 'feed-slice'
slice: PostsFeedSliceModel
}
| {
_reactKey: string
type: 'feed-loading'
}
| {
_reactKey: string
type: 'feed-error'
error: string
}
| {
_reactKey: string
type: 'feed-footer'
title: string
uri: string
}
| {
_reactKey: string
type: 'footer'
}
export class PostsMultiFeedModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
hasMore = true
// data
feedInfos: CustomFeedModel[] = []
feeds: PostsFeedModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
}
get hasContent() {
return this.feeds.length !== 0
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get items() {
const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
for (let i = 0; i < this.feedInfos.length; i++) {
if (!this.feeds[i]) {
break
}
const feed = this.feeds[i]
const feedInfo = this.feedInfos[i]
const urip = new AtUri(feedInfo.uri)
items.push({
_reactKey: `__feed_header_${i}__`,
type: 'feed-header',
avatar: feedInfo.data.avatar,
title: feedInfo.displayName,
})
if (feed.isLoading) {
items.push({
_reactKey: `__feed_loading_${i}__`,
type: 'feed-loading',
})
} else if (feed.hasError) {
items.push({
_reactKey: `__feed_error_${i}__`,
type: 'feed-error',
error: feed.error,
})
} else {
for (let j = 0; j < feed.slices.length; j++) {
items.push({
_reactKey: `__feed_slice_${i}_${j}__`,
type: 'feed-slice',
slice: feed.slices[j],
})
}
}
items.push({
_reactKey: `__feed_footer_${i}__`,
type: 'feed-footer',
title: feedInfo.displayName,
uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey),
})
}
if (!this.hasMore && this.hasContent) {
// only show if hasContent to avoid double discover-feed links
items.push({_reactKey: '__footer__', type: 'footer'})
}
return items
}
// public api
// =
/**
* Nuke all data
*/
clear() {
this.rootStore.log.debug('MultiFeedModel:clear')
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.hasMore = true
this.feeds = []
}
/**
* Register any event listeners. Returns a cleanup function.
*/
registerListeners() {
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
return () => sub.remove()
}
/**
* Reset and load
*/
async refresh() {
this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
await this.loadMore(true)
}
/**
* Load latest in the active feeds
*/
loadLatest() {
for (const feed of this.feeds) {
/* dont await */ feed.refresh()
}
}
/**
* Load more posts to the end of the feed
*/
loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
if (!isRefreshing && !this.hasMore) {
return
}
if (isRefreshing) {
this.isRefreshing = true // set optimistically for UI
this.feeds = []
}
this._xLoading(isRefreshing)
const start = this.feeds.length
const newFeeds: PostsFeedModel[] = []
for (
let i = start;
i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
i++
) {
const feed = new PostsFeedModel(this.rootStore, 'custom', {
feed: this.feedInfos[i].uri,
})
feed.pageSize = FEED_PAGE_SIZE
await feed.setup()
newFeeds.push(feed)
}
runInAction(() => {
this.feeds = this.feeds.concat(newFeeds)
this.hasMore = this.feeds.length < this.feedInfos.length
})
this._xIdle()
})
/**
* Attempt to load more again after a failure
*/
async retryLoadMore() {
this.hasMore = true
return this.loadMore()
}
/**
* Removes posts from the feed upon deletion.
*/
onPostDeleted(uri: string) {
for (const f of this.feeds) {
f.onPostDeleted(uri)
}
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
}
_xIdle() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
}
// helper functions
// =
}

View File

@ -2,6 +2,7 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {PostsFeedItemModel} from './post'
import {FeedSourceInfo} from 'lib/api/feed/types'
export class PostsFeedSliceModel {
// ui state
@ -9,9 +10,11 @@ export class PostsFeedSliceModel {
// data
items: PostsFeedItemModel[] = []
source: FeedSourceInfo | undefined
constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
this._reactKey = slice._reactKey
this.source = slice.source
for (let i = 0; i < slice.items.length; i++) {
this.items.push(
new PostsFeedItemModel(

View File

@ -14,6 +14,13 @@ import {PostsFeedSliceModel} from './posts-slice'
import {track} from 'lib/analytics/analytics'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {MergeFeedAPI} from 'lib/api/feed/merge'
const PAGE_SIZE = 30
type Options = {
@ -27,6 +34,7 @@ type Options = {
type QueryParams =
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
export class PostsFeedModel {
@ -41,8 +49,8 @@ export class PostsFeedModel {
loadMoreError = ''
params: QueryParams
hasMore = true
loadMoreCursor: string | undefined
pollCursor: string | undefined
api: FeedAPI
tuner = new FeedTuner()
pageSize = PAGE_SIZE
options: Options = {}
@ -50,7 +58,7 @@ export class PostsFeedModel {
// used to linearize async modifications to state
lock = new AwaitLock()
// used to track if what's hot is coming up empty
// used to track if a feed is coming up empty
emptyFetches = 0
// data
@ -58,7 +66,7 @@ export class PostsFeedModel {
constructor(
public rootStore: RootStoreModel,
public feedType: 'home' | 'author' | 'custom' | 'likes',
public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
params: QueryParams,
options?: Options,
) {
@ -67,12 +75,33 @@ export class PostsFeedModel {
{
rootStore: false,
params: false,
loadMoreCursor: false,
},
{autoBind: true},
)
this.params = params
this.options = options || {}
if (feedType === 'home') {
this.api = new MergeFeedAPI(rootStore)
} else if (feedType === 'following') {
this.api = new FollowingFeedAPI(rootStore)
} else if (feedType === 'author') {
this.api = new AuthorFeedAPI(
rootStore,
params as GetAuthorFeed.QueryParams,
)
} else if (feedType === 'likes') {
this.api = new LikesFeedAPI(
rootStore,
params as GetActorLikes.QueryParams,
)
} else if (feedType === 'custom') {
this.api = new CustomFeedAPI(
rootStore,
params as GetCustomFeed.QueryParams,
)
} else {
this.api = new FollowingFeedAPI(rootStore)
}
}
get hasContent() {
@ -105,7 +134,6 @@ export class PostsFeedModel {
this.hasLoaded = false
this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.pollCursor = undefined
this.slices = []
this.tuner.reset()
@ -113,6 +141,8 @@ export class PostsFeedModel {
get feedTuners() {
const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
const areRepliesByFollowedOnlyEnabled =
this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled
const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
const areQuotePostsEnabled =
@ -126,7 +156,7 @@ export class PostsFeedModel {
),
]
}
if (this.feedType === 'home') {
if (this.feedType === 'home' || this.feedType === 'following') {
const feedTuners = []
if (areRepostsEnabled) {
@ -136,7 +166,13 @@ export class PostsFeedModel {
}
if (areRepliesEnabled) {
feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold}))
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: repliesThreshold,
followedOnly: areRepliesByFollowedOnlyEnabled,
}),
)
} else {
feedTuners.push(FeedTuner.removeReplies)
}
@ -161,10 +197,11 @@ export class PostsFeedModel {
await this.lock.acquireAsync()
try {
this.setHasNewLatest(false)
this.api.reset()
this.tuner.reset()
this._xLoading(isRefreshing)
try {
const res = await this._getFeed({limit: this.pageSize})
const res = await this.api.fetchNext({limit: this.pageSize})
await this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@ -201,8 +238,7 @@ export class PostsFeedModel {
}
this._xLoading()
try {
const res = await this._getFeed({
cursor: this.loadMoreCursor,
const res = await this.api.fetchNext({
limit: this.pageSize,
})
await this._appendAll(res)
@ -230,44 +266,6 @@ export class PostsFeedModel {
return this.loadMore()
}
/**
* Update content in-place
*/
update = bundleAsync(async () => {
await this.lock.acquireAsync()
try {
if (!this.slices.length) {
return
}
this._xLoading()
let numToFetch = this.slices.length
let cursor
try {
do {
const res: GetTimeline.Response = await this._getFeed({
cursor,
limit: Math.min(numToFetch, 100),
})
if (res.data.feed.length === 0) {
break // sanity check
}
this._updateAll(res)
numToFetch -= res.data.feed.length
cursor = res.data.cursor
} while (cursor && numToFetch > 0)
this._xIdle()
} catch (e: any) {
this._xIdle() // don't bubble the error to the user
this.rootStore.log.error('FeedView: Failed to update', {
params: this.params,
e,
})
}
} finally {
this.lock.release()
}
})
/**
* Check if new posts are available
*/
@ -275,9 +273,9 @@ export class PostsFeedModel {
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
return
}
const res = await this._getFeed({limit: 1})
if (res.data.feed[0]) {
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
const post = await this.api.peekLatest()
if (post) {
const slices = this.tuner.tune([post], this.feedTuners, {
dryRun: true,
})
if (slices[0]) {
@ -345,33 +343,27 @@ export class PostsFeedModel {
// helper functions
// =
async _replaceAll(
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
) {
this.pollCursor = res.data.feed[0]?.post.uri
async _replaceAll(res: FeedAPIResponse) {
this.pollCursor = res.feed[0]?.post.uri
return this._appendAll(res, true)
}
async _appendAll(
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
replace = false,
) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
async _appendAll(res: FeedAPIResponse, replace = false) {
this.hasMore = !!res.cursor
if (replace) {
this.emptyFetches = 0
}
this.rootStore.me.follows.hydrateProfiles(
res.data.feed.map(item => item.post.author),
res.feed.map(item => item.post.author),
)
for (const item of res.data.feed) {
for (const item of res.feed) {
this.rootStore.posts.fromFeedItem(item)
}
const slices = this.options.isSimpleFeed
? res.data.feed.map(item => new FeedViewPostsSlice([item]))
: this.tuner.tune(res.data.feed, this.feedTuners)
? res.feed.map(item => new FeedViewPostsSlice([item]))
: this.tuner.tune(res.feed, this.feedTuners)
const toAppend: PostsFeedSliceModel[] = []
for (const slice of slices) {
@ -401,54 +393,4 @@ export class PostsFeedModel {
}
})
}
_updateAll(
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
) {
for (const item of res.data.feed) {
this.rootStore.posts.fromFeedItem(item)
const existingSlice = this.slices.find(slice =>
slice.containsUri(item.post.uri),
)
if (existingSlice) {
const existingItem = existingSlice.items.find(
item2 => item2.post.uri === item.post.uri,
)
if (existingItem) {
existingItem.copyMetrics(item)
}
}
}
}
protected async _getFeed(
params: QueryParams,
): Promise<
GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
> {
params = Object.assign({}, this.params, params)
if (this.feedType === 'home') {
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
} else if (this.feedType === 'custom') {
const res = await this.rootStore.agent.app.bsky.feed.getFeed(
params as GetCustomFeed.QueryParams,
)
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (params.limit && res.data.feed.length > params.limit) {
res.data.feed = res.data.feed.slice(0, params.limit)
}
return res
} else if (this.feedType === 'author') {
return this.rootStore.agent.getAuthorFeed(
params as GetAuthorFeed.QueryParams,
)
} else {
return this.rootStore.agent.getActorLikes(
params as GetActorLikes.QueryParams,
)
}
}
}

View File

@ -139,7 +139,7 @@ export class RootStoreModel {
this.agent = agent
applyDebugHeader(this.agent)
this.me.clear()
/* dont await */ this.preferences.sync()
await this.preferences.sync()
await this.me.load()
if (!hadSession) {
await resetNavigation()

View File

@ -0,0 +1,157 @@
import {makeAutoObservable} from 'mobx'
import {FeedsDiscoveryModel} from '../discovery/feeds'
import {CustomFeedModel} from '../feeds/custom-feed'
import {RootStoreModel} from '../root-store'
export type MyFeedsItem =
| {
_reactKey: string
type: 'spinner'
}
| {
_reactKey: string
type: 'discover-feeds-loading'
}
| {
_reactKey: string
type: 'error'
error: string
}
| {
_reactKey: string
type: 'saved-feeds-header'
}
| {
_reactKey: string
type: 'saved-feed'
feed: CustomFeedModel
}
| {
_reactKey: string
type: 'saved-feeds-load-more'
}
| {
_reactKey: string
type: 'discover-feeds-header'
}
| {
_reactKey: string
type: 'discover-feeds-no-results'
}
| {
_reactKey: string
type: 'discover-feed'
feed: CustomFeedModel
}
export class MyFeedsUIModel {
discovery: FeedsDiscoveryModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
this.discovery = new FeedsDiscoveryModel(this.rootStore)
}
get saved() {
return this.rootStore.me.savedFeeds
}
get isRefreshing() {
return !this.saved.isLoading && this.saved.isRefreshing
}
get isLoading() {
return this.saved.isLoading || this.discovery.isLoading
}
async setup() {
if (!this.saved.hasLoaded) {
await this.saved.refresh()
}
if (!this.discovery.hasLoaded) {
await this.discovery.refresh()
}
}
async refresh() {
return Promise.all([this.saved.refresh(), this.discovery.refresh()])
}
async loadMore() {
return this.discovery.loadMore()
}
get items() {
let items: MyFeedsItem[] = []
items.push({
_reactKey: '__saved_feeds_header__',
type: 'saved-feeds-header',
})
if (this.saved.isLoading) {
items.push({
_reactKey: '__saved_feeds_loading__',
type: 'spinner',
})
} else if (this.saved.hasError) {
items.push({
_reactKey: '__saved_feeds_error__',
type: 'error',
error: this.saved.error,
})
} else {
const savedSorted = this.saved.all
.slice()
.sort((a, b) => a.displayName.localeCompare(b.displayName))
items = items.concat(
savedSorted.map(feed => ({
_reactKey: `saved-${feed.uri}`,
type: 'saved-feed',
feed,
})),
)
items.push({
_reactKey: '__saved_feeds_load_more__',
type: 'saved-feeds-load-more',
})
}
items.push({
_reactKey: '__discover_feeds_header__',
type: 'discover-feeds-header',
})
if (this.discovery.isLoading && !this.discovery.hasContent) {
items.push({
_reactKey: '__discover_feeds_loading__',
type: 'discover-feeds-loading',
})
} else if (this.discovery.hasError) {
items.push({
_reactKey: '__discover_feeds_error__',
type: 'error',
error: this.discovery.error,
})
} else if (this.discovery.isEmpty) {
items.push({
_reactKey: '__discover_feeds_no_results__',
type: 'discover-feeds-no-results',
})
} else {
items = items.concat(
this.discovery.feeds.map(feed => ({
_reactKey: `discover-${feed.uri}`,
type: 'discover-feed',
feed,
})),
)
if (this.discovery.isLoading) {
items.push({
_reactKey: '__discover_feeds_loading_more__',
type: 'spinner',
})
}
}
return items
}
}

View File

@ -50,9 +50,11 @@ export class PreferencesModel {
pinnedFeeds: string[] = []
birthDate: Date | undefined = undefined
homeFeedRepliesEnabled: boolean = true
homeFeedRepliesThreshold: number = 2
homeFeedRepliesByFollowedOnlyEnabled: boolean = true
homeFeedRepliesThreshold: number = 0
homeFeedRepostsEnabled: boolean = true
homeFeedQuotePostsEnabled: boolean = true
homeFeedMergeFeedEnabled: boolean = false
requireAltTextEnabled: boolean = false
// used to linearize async modifications to state
@ -78,9 +80,12 @@ export class PreferencesModel {
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
homeFeedRepliesEnabled: this.homeFeedRepliesEnabled,
homeFeedRepliesByFollowedOnlyEnabled:
this.homeFeedRepliesByFollowedOnlyEnabled,
homeFeedRepliesThreshold: this.homeFeedRepliesThreshold,
homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
requireAltTextEnabled: this.requireAltTextEnabled,
}
}
@ -148,6 +153,14 @@ export class PreferencesModel {
) {
this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled
}
// check if home feed replies "followed only" are enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') &&
typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean'
) {
this.homeFeedRepliesByFollowedOnlyEnabled =
v.homeFeedRepliesByFollowedOnlyEnabled
}
// check if home feed replies threshold is enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedRepliesThreshold') &&
@ -169,6 +182,13 @@ export class PreferencesModel {
) {
this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled
}
// check if home feed mergefeed is enabled in preferences, then hydrate
if (
hasProp(v, 'homeFeedMergeFeedEnabled') &&
typeof v.homeFeedMergeFeedEnabled === 'boolean'
) {
this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
}
// check if requiring alt text is enabled in preferences, then hydrate
if (
hasProp(v, 'requireAltTextEnabled') &&
@ -449,6 +469,11 @@ export class PreferencesModel {
this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled
}
toggleHomeFeedRepliesByFollowedOnlyEnabled() {
this.homeFeedRepliesByFollowedOnlyEnabled =
!this.homeFeedRepliesByFollowedOnlyEnabled
}
setHomeFeedRepliesThreshold(threshold: number) {
this.homeFeedRepliesThreshold = threshold
}
@ -461,6 +486,10 @@ export class PreferencesModel {
this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled
}
toggleHomeFeedMergeFeedEnabled() {
this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
}
toggleRequireAltTextEnabled() {
this.requireAltTextEnabled = !this.requireAltTextEnabled
}

View File

@ -240,13 +240,6 @@ export class ProfileUiModel {
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
}
async update() {
const view = this.currentView
if (view instanceof PostsFeedModel) {
await view.update()
}
}
async refresh() {
await Promise.all([this.profile.refresh(), this.currentView.refresh()])
}

View File

@ -21,11 +21,13 @@ export const Feed = observer(function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
ListHeaderComponent,
}: {
view: NotificationsFeedModel
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
ListHeaderComponent?: () => JSX.Element
}) {
const pal = usePalette('default')
const [isPTRing, setIsPTRing] = React.useState(false)
@ -142,6 +144,7 @@ export const Feed = observer(function Feed({
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={FeedFooter}
refreshControl={
<RefreshControl
@ -156,6 +159,8 @@ export const Feed = observer(function Feed({
onScroll={onScroll}
scrollEventThrottle={100}
contentContainerStyle={s.contentContainer}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
) : null}
</View>

View File

@ -12,15 +12,17 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isMobile} = useWebMediaQueries()
const {isMobile, isTablet} = useWebMediaQueries()
if (isMobile) {
return <FeedsTabBarMobile {...props} />
} else if (isTablet) {
return <FeedsTabBarTablet {...props} />
} else {
return <FeedsTabBarDesktop {...props} />
return null
}
})
const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const store = useStores()

View File

@ -9,8 +9,8 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {CogIcon} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {HITSLOP_10} from 'lib/constants'
@ -67,12 +67,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
</Text>
<View style={[pal.view]}>
<Link
href="/settings/saved-feeds"
href="/settings/home-feed"
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel="Edit Saved Feeds"
accessibilityHint="Opens screen to edit Saved Feeds">
<CogIcon size={21} strokeWidth={2} style={pal.textLight} />
accessibilityLabel="Home Feed Preferences"
accessibilityHint="">
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
</Link>
</View>
</View>

View File

@ -357,6 +357,8 @@ export const PostThread = observer(function PostThread({
}
onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)
})

View File

@ -483,15 +483,6 @@ export const PostThreadItem = observer(function PostThreadItem({
/>
</ContentHider>
)}
{needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate">
<Text type="sm" style={pal.link}>
Translate this post
</Text>
</Link>
</View>
)}
<PostCtrls
itemUri={itemUri}
itemCid={itemCid}

View File

@ -1,4 +1,4 @@
import React, {useState, useMemo} from 'react'
import React, {useState} from 'react'
import {
ActivityIndicator,
Linking,
@ -28,7 +28,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {getTranslatorLink} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links'
export const Post = observer(function PostImpl({
@ -116,12 +116,6 @@ const PostLoaded = observer(function PostLoadedImpl({
}
const translatorUrl = getTranslatorLink(record?.text || '')
const needsTranslation = useMemo(
() =>
store.preferences.contentLanguages.length > 0 &&
!isPostInLanguage(item.post, store.preferences.contentLanguages),
[item.post, store.preferences.contentLanguages],
)
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
@ -256,15 +250,6 @@ const PostLoaded = observer(function PostLoadedImpl({
/>
</ContentHider>
) : null}
{needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate">
<Text type="sm" style={pal.link}>
Translate this post
</Text>
</Link>
</View>
)}
</ContentHider>
<PostCtrls
itemUri={itemUri}
@ -322,9 +307,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexWrap: 'wrap',
},
translateLink: {
marginBottom: 12,
},
replyLine: {
position: 'absolute',
left: 36,

View File

@ -8,6 +8,7 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostsFeedItemModel} from 'state/models/feeds/post'
import {FeedSourceInfo} from 'lib/api/feed/types'
import {Link, DesktopWebTextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText'
@ -26,17 +27,19 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {getTranslatorLink} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links'
import {isEmbedByEmbedder} from 'lib/embeds'
export const FeedItem = observer(function FeedItemImpl({
item,
source,
isThreadChild,
isThreadLastChild,
isThreadParent,
}: {
item: PostsFeedItemModel
source?: FeedSourceInfo
isThreadChild?: boolean
isThreadLastChild?: boolean
isThreadParent?: boolean
@ -62,12 +65,6 @@ export const FeedItem = observer(function FeedItemImpl({
return urip.hostname
}, [record?.reply])
const translatorUrl = getTranslatorLink(record?.text || '')
const needsTranslation = useMemo(
() =>
store.preferences.contentLanguages.length > 0 &&
!isPostInLanguage(item.post, store.preferences.contentLanguages),
[item.post, store.preferences.contentLanguages],
)
const onPressReply = React.useCallback(() => {
track('FeedItem:PostReply')
@ -179,7 +176,27 @@ export const FeedItem = observer(function FeedItemImpl({
</View>
<View style={{paddingTop: 12}}>
{item.reasonRepost && (
{source ? (
<Link
title={sanitizeDisplayName(source.displayName)}
href={source.uri}>
<Text
type="sm-bold"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}>
From{' '}
<DesktopWebTextLink
type="sm-bold"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
text={sanitizeDisplayName(source.displayName)}
href={source.uri}
/>
</Text>
</Link>
) : item.reasonRepost ? (
<Link
style={styles.includeReason}
href={makeProfileLink(item.reasonRepost.by)}
@ -188,10 +205,10 @@ export const FeedItem = observer(function FeedItemImpl({
)}>
<FontAwesomeIcon
icon="retweet"
style={[
styles.includeReasonIcon,
{color: pal.colors.textLight} as FontAwesomeIconStyle,
]}
style={{
marginRight: 4,
color: pal.colors.textLight,
}}
/>
<Text
type="sm-bold"
@ -212,7 +229,7 @@ export const FeedItem = observer(function FeedItemImpl({
/>
</Text>
</Link>
)}
) : null}
</View>
</View>
@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({
/>
</ContentHider>
) : null}
{needsTranslation && (
<View style={[pal.borderDark, styles.translateLink]}>
<Link href={translatorUrl} title="Translate">
<Text type="sm" style={pal.link}>
Translate this post
</Text>
</Link>
</View>
)}
</ContentHider>
<PostCtrls
itemUri={itemUri}
@ -362,12 +370,9 @@ const styles = StyleSheet.create({
includeReason: {
flexDirection: 'row',
marginTop: 2,
marginBottom: 4,
marginBottom: 2,
marginLeft: -20,
},
includeReasonIcon: {
marginRight: 4,
},
layout: {
flexDirection: 'row',
marginTop: 1,

View File

@ -28,6 +28,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
<FeedItem
key={slice.items[0]._reactKey}
item={slice.items[0]}
source={slice.source}
isThreadParent={slice.isThreadParentAt(0)}
isThreadChild={slice.isThreadChildAt(0)}
/>
@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
<FeedItem
key={item._reactKey}
item={item}
source={i === 0 ? slice.source : undefined}
isThreadParent={slice.isThreadParentAt(i)}
isThreadChild={slice.isThreadChildAt(i)}
isThreadLastChild={

View File

@ -28,7 +28,7 @@ export function FollowingEmptyState() {
}, [navigation])
const onPressDiscoverFeeds = React.useCallback(() => {
navigation.navigate('DiscoverFeeds')
navigation.navigate('Feeds')
}, [navigation])
return (

View File

@ -1,256 +0,0 @@
import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed'
import {FeedSlice} from './FeedSlice'
import {Text} from '../util/text/Text'
import {Link} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CogIcon} from 'lib/icons'
export const MultiFeed = observer(function Feed({
multifeed,
style,
scrollElRef,
onScroll,
scrollEventThrottle,
testID,
headerOffset = 0,
extraData,
}: {
multifeed: PostsMultiFeedModel
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
scrollEventThrottle?: number
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
extraData?: any
}) {
const pal = usePalette('default')
const theme = useTheme()
const {isMobile} = useWebMediaQueries()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
// events
// =
const onRefresh = React.useCallback(async () => {
track('MultiFeed:onRefresh')
setIsRefreshing(true)
try {
await multifeed.refresh()
} catch (err) {
multifeed.rootStore.log.error('Failed to refresh posts feed', err)
}
setIsRefreshing(false)
}, [multifeed, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('MultiFeed:onEndReached')
try {
await multifeed.loadMore()
} catch (err) {
multifeed.rootStore.log.error('Failed to load more posts', err)
}
}, [multifeed, track])
// rendering
// =
const renderItem = React.useCallback(
({item}: {item: MultiFeedItem}) => {
if (item.type === 'header') {
if (!isMobile) {
return (
<>
<View style={[pal.view, pal.border, styles.headerDesktop]}>
<Text type="2xl-bold" style={pal.text}>
My Feeds
</Text>
<Link href="/settings/saved-feeds">
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
</Link>
</View>
<DiscoverLink />
</>
)
}
return (
<>
<View style={[styles.header, pal.border]} />
<DiscoverLink />
</>
)
} else if (item.type === 'feed-header') {
return (
<View style={styles.feedHeader}>
<UserAvatar type="algo" avatar={item.avatar} size={28} />
<Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}>
{item.title}
</Text>
</View>
)
} else if (item.type === 'feed-slice') {
return <FeedSlice slice={item.slice} />
} else if (item.type === 'feed-loading') {
return <PostFeedLoadingPlaceholder />
} else if (item.type === 'feed-error') {
return <ErrorMessage message={item.error} />
} else if (item.type === 'feed-footer') {
return (
<Link
href={item.uri}
style={[styles.feedFooter, pal.border, pal.view]}>
<Text type="lg" style={pal.link}>
See more from {item.title}
</Text>
<FontAwesomeIcon
icon="angle-right"
size={18}
color={pal.colors.link}
/>
</Link>
)
} else if (item.type === 'footer') {
return <DiscoverLink />
}
return null
},
[pal, isMobile],
)
const ListFooter = React.useCallback(
() =>
multifeed.isLoading && !isRefreshing ? (
<View style={styles.loadMore}>
<ActivityIndicator color={pal.colors.text} />
</View>
) : (
<View />
),
[multifeed.isLoading, isRefreshing, pal],
)
return (
<View testID={testID} style={style}>
{multifeed.items.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={multifeed.items}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={ListFooter}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={[{paddingTop: headerOffset}, pal.view, styles.container]}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
extraData={extraData}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</View>
)
})
function DiscoverLink() {
const pal = usePalette('default')
return (
<Link style={[styles.discoverLink, pal.viewLight]} href="/search/feeds">
<FontAwesomeIcon icon="search" size={18} color={pal.colors.text} />
<Text type="xl-medium" style={pal.text}>
Discover new feeds
</Text>
</Link>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
},
header: {
borderTopWidth: 1,
marginBottom: 4,
},
headerDesktop: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
marginBottom: 4,
paddingHorizontal: 16,
paddingVertical: 8,
},
feedHeader: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
marginTop: 12,
},
feedHeaderTitle: {
fontWeight: 'bold',
},
feedFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
marginBottom: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
discoverLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
marginHorizontal: 8,
marginVertical: 8,
gap: 8,
},
loadMore: {
paddingTop: 10,
},
})

View File

@ -26,6 +26,7 @@ import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
import {isAndroid, isDesktopWeb} from 'platform/detection'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover'
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
type Event =
@ -38,6 +39,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
href?: string
title?: string
children?: React.ReactNode
hoverStyle?: StyleProp<ViewStyle>
noFeedback?: boolean
asAnchor?: boolean
anchorNoUnderline?: boolean
@ -112,8 +114,9 @@ export const Link = observer(function Link({
props.accessibilityLabel = title
}
const Com = props.hoverStyle ? PressableWithHover : Pressable
return (
<Pressable
<Com
testID={testID}
style={style}
onPress={onPress}
@ -123,7 +126,7 @@ export const Link = observer(function Link({
href={asAnchor ? sanitizeUrl(href) : undefined}
{...props}>
{children ? children : <Text>{title || 'link'}</Text>}
</Pressable>
</Com>
)
})
@ -137,6 +140,7 @@ export const TextLink = observer(function TextLink({
lineHeight,
dataSet,
title,
onPress,
}: {
testID?: string
type?: TypographyVariant
@ -154,9 +158,14 @@ export const TextLink = observer(function TextLink({
props.onPress = React.useCallback(
(e?: Event) => {
if (onPress) {
e?.preventDefault?.()
// @ts-ignore function signature differs by platform -prf
return onPress()
}
return onPressInner(store, navigation, sanitizeUrl(href), e)
},
[store, navigation, href],
[onPress, store, navigation, href],
)
const hrefAttrs = useMemo(() => {
const isExternal = isExternalUrl(href)

View File

@ -174,6 +174,60 @@ export function ProfileCardFeedLoadingPlaceholder() {
)
}
export function FeedLoadingPlaceholder({
style,
}: {
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
return (
<View
style={[
{paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1},
pal.border,
style,
]}>
<View style={[pal.view, {flexDirection: 'row', marginBottom: 10}]}>
<LoadingPlaceholder
width={36}
height={36}
style={[styles.avatar, {borderRadius: 6}]}
/>
<View style={[s.flex1]}>
<LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
<LoadingPlaceholder width={120} height={8} />
</View>
</View>
<View style={{paddingHorizontal: 5}}>
<LoadingPlaceholder
width={260}
height={8}
style={{marginVertical: 12}}
/>
<LoadingPlaceholder width={120} height={8} />
</View>
</View>
)
}
export function FeedFeedLoadingPlaceholder() {
return (
<>
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
<FeedLoadingPlaceholder />
</>
)
}
const styles = StyleSheet.create({
loadingPlaceholder: {
borderRadius: 6,

View File

@ -0,0 +1,105 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {CenteredView} from './Views'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
showBackButton = true,
style,
children,
}: React.PropsWithChildren<{
showBackButton?: boolean
style?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isMobile} = useWebMediaQueries()
const canGoBack = navigation.canGoBack()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
store.shell.openDrawer()
}, [track, store])
const Container = isMobile ? View : CenteredView
return (
<Container style={[styles.header, isMobile && styles.headerMobile, style]}>
{showBackButton ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityHint="">
{canGoBack ? (
<FontAwesomeIcon
size={18}
icon="angle-left"
style={[styles.backIcon, pal.text]}
/>
) : (
<FontAwesomeIcon
size={18}
icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
)}
</TouchableOpacity>
) : null}
{children}
</Container>
)
})
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 12,
width: '100%',
},
headerMobile: {
paddingHorizontal: 12,
paddingVertical: 10,
},
backBtn: {
width: 30,
height: 30,
},
backBtnWide: {
width: 30,
height: 30,
paddingHorizontal: 6,
},
backIcon: {
marginTop: 6,
},
})

View File

@ -118,7 +118,7 @@ export function UserAvatar({
return {
width: size,
height: size,
borderRadius: 8,
borderRadius: size > 32 ? 8 : 3,
}
}
return {

View File

@ -0,0 +1,104 @@
import React from 'react'
import {
StyleProp,
StyleSheet,
TextInput,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {MagnifyingGlassIcon} from 'lib/icons'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
interface Props {
query: string
setIsInputFocused?: (v: boolean) => void
onChangeQuery: (v: string) => void
onPressCancelSearch: () => void
onSubmitQuery: () => void
style?: StyleProp<ViewStyle>
}
export function SearchInput({
query,
setIsInputFocused,
onChangeQuery,
onPressCancelSearch,
onSubmitQuery,
style,
}: Props) {
const theme = useTheme()
const pal = usePalette('default')
const textInput = React.useRef<TextInput>(null)
const onPressCancelSearchInner = React.useCallback(() => {
onPressCancelSearch()
textInput.current?.blur()
}, [onPressCancelSearch, textInput])
return (
<View style={[pal.viewLight, styles.container, style]}>
<MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.input]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setIsInputFocused?.(true)}
onBlur={() => setIsInputFocused?.(false)}
onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery}
accessibilityRole="search"
accessibilityLabel="Search"
accessibilityHint=""
autoCorrect={false}
autoCapitalize="none"
/>
{query ? (
<TouchableOpacity
onPress={onPressCancelSearchInner}
accessibilityRole="button"
accessibilityLabel="Clear search query"
accessibilityHint="">
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 30,
paddingHorizontal: 12,
paddingVertical: 8,
},
icon: {
marginRight: 6,
alignSelf: 'center',
},
input: {
flex: 1,
fontSize: 17,
minWidth: 0, // overflow mitigation for firefox
},
cancelBtn: {
paddingLeft: 10,
},
})

View File

@ -1 +1,86 @@
export * from './LoadLatestBtnMobile'
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lodash'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
onPress,
label,
showIndicator,
}: {
onPress: () => void
label: string
showIndicator: boolean
minimalShellMode?: boolean // NOTE not used on mobile -prf
}) {
const store = useStores()
const pal = usePalette('default')
const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
const safeAreaInsets = useSafeAreaInsets()
return (
<TouchableOpacity
style={[
styles.loadLatest,
isDesktop && styles.loadLatestDesktop,
isTablet && styles.loadLatestTablet,
pal.borderDark,
pal.view,
isMobile &&
!store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
},
]}
onPress={onPress}
hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
</TouchableOpacity>
)
})
const styles = StyleSheet.create({
loadLatest: {
position: 'absolute',
left: 18,
bottom: 35,
borderWidth: 1,
width: 52,
height: 52,
borderRadius: 26,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
loadLatestTablet: {
// @ts-ignore web only
left: '50vw',
// @ts-ignore web only -prf
transform: 'translateX(-282px)',
},
loadLatestDesktop: {
// @ts-ignore web only
left: '50vw',
// @ts-ignore web only -prf
transform: 'translateX(-382px)',
},
indicator: {
position: 'absolute',
top: 3,
right: 3,
backgroundColor: colors.blue3,
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 1,
},
})

View File

@ -1,109 +0,0 @@
import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile'
import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = ({
onPress,
label,
showIndicator,
minimalShellMode,
}: {
onPress: () => void
label: string
showIndicator: boolean
minimalShellMode?: boolean
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
if (isMobile) {
return (
<LoadLatestBtnMobile
onPress={onPress}
label={label}
showIndicator={showIndicator}
/>
)
}
return (
<>
{showIndicator && (
<TouchableOpacity
style={[
pal.view,
pal.borderDark,
styles.loadLatestCentered,
minimalShellMode && styles.loadLatestCenteredMinimal,
]}
onPress={onPress}
hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
<Text type="md-bold" style={pal.text}>
{label}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[pal.view, pal.borderDark, styles.loadLatest]}
onPress={onPress}
hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
<Text type="md-bold" style={pal.text}>
<FontAwesomeIcon
icon="angle-up"
size={21}
style={[pal.text, styles.icon]}
/>
</Text>
</TouchableOpacity>
</>
)
}
const styles = StyleSheet.create({
loadLatest: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
// @ts-ignore web only
left: '50vw',
// @ts-ignore web only -prf
transform: 'translateX(-282px)',
bottom: 40,
width: 54,
height: 54,
borderRadius: 30,
borderWidth: 1,
},
icon: {
position: 'relative',
top: 2,
},
loadLatestCentered: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
// @ts-ignore web only
left: '50vw',
// @ts-ignore web only -prf
transform: 'translateX(-50%)',
top: 60,
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 30,
borderWidth: 1,
},
loadLatestCenteredMinimal: {
top: 20,
},
})

View File

@ -1,69 +0,0 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lodash'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
onPress,
label,
showIndicator,
}: {
onPress: () => void
label: string
showIndicator: boolean
minimalShellMode?: boolean // NOTE not used on mobile -prf
}) {
const store = useStores()
const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets()
return (
<TouchableOpacity
style={[
styles.loadLatest,
pal.borderDark,
pal.view,
!store.shell.minimalShellMode && {
bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
},
]}
onPress={onPress}
hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
<FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
{showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
</TouchableOpacity>
)
})
const styles = StyleSheet.create({
loadLatest: {
position: 'absolute',
left: 18,
bottom: 35,
borderWidth: 1,
width: 52,
height: 52,
borderRadius: 26,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
indicator: {
position: 'absolute',
top: 3,
right: 3,
backgroundColor: colors.blue3,
width: 12,
height: 12,
borderRadius: 6,
borderWidth: 1,
},
})

View File

@ -13,6 +13,7 @@ import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrow
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
@ -24,6 +25,7 @@ import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faB
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
@ -41,6 +43,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@ -54,15 +57,18 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
import {faList} from '@fortawesome/free-solid-svg-icons/faList'
import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
@ -77,6 +83,7 @@ import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
@ -88,11 +95,6 @@ import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faList} from '@fortawesome/free-solid-svg-icons/faList'
import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
export function setup() {
library.add(
@ -109,6 +111,7 @@ export function setup() {
faArrowUpFromBracket,
faArrowUpRightFromSquare,
faArrowRotateLeft,
faArrowTrendUp,
faArrowsRotate,
faAt,
faBan,
@ -120,6 +123,7 @@ export function setup() {
farCalendar,
faCamera,
faCheck,
faChevronRight,
faCircle,
faCircleCheck,
farCircleCheck,
@ -137,6 +141,7 @@ export function setup() {
faExclamation,
farEyeSlash,
faFaceSmile,
faFire,
faFloppyDisk,
faGear,
faGlobe,
@ -150,15 +155,18 @@ export function setup() {
faInfo,
faLanguage,
faLink,
faList,
faListUl,
faLock,
faMagnifyingGlass,
faMessage,
faNoteSticky,
faPaste,
faPause,
faPen,
faPenNib,
faPenToSquare,
faPlay,
faPlus,
faQuoteLeft,
faReply,
@ -180,14 +188,10 @@ export function setup() {
faUserPlus,
faUserXmark,
faUsersSlash,
faThumbtack,
faTicket,
faTrashCan,
faThumbtack,
faX,
faXmark,
faPlay,
faPause,
faList,
faChevronRight,
)
}

View File

@ -1,7 +1,7 @@
import React, {useMemo, useRef} from 'react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {useNavigation, useIsFocused} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {HeartIcon, HeartIconSolid} from 'lib/icons'
import {CommonNavigatorParams} from 'lib/routes/types'
@ -14,11 +14,8 @@ import {PostsFeedModel} from 'state/models/feeds/posts'
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {Feed} from 'view/com/posts/Feed'
import {pluralize} from 'lib/strings/helpers'
import {sanitizeHandle} from 'lib/strings/handles'
import {TextLink} from 'view/com/util/Link'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
@ -34,7 +31,6 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState'
import {useAnalytics} from 'lib/analytics/analytics'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {makeProfileLink} from 'lib/routes/links'
import {resolveName} from 'lib/api'
import {CenteredView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
@ -125,7 +121,10 @@ export const CustomFeedScreenInner = observer(
}: Props & {feedOwnerDid: string}) {
const store = useStores()
const pal = usePalette('default')
const {isTabletOrDesktop} = useWebMediaQueries()
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const isScreenFocused = useIsFocused()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const {track} = useAnalytics()
const {rkey, name: handleOrDid} = route.params
const uri = useMemo(
@ -186,6 +185,10 @@ export const CustomFeedScreenInner = observer(
})
}, [store, currentFeed])
const onPressViewAuthor = React.useCallback(() => {
navigation.navigate('Profile', {name: handleOrDid})
}, [handleOrDid, navigation])
const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
shareUrl(url)
@ -210,8 +213,39 @@ export const CustomFeedScreenInner = observer(
store.shell.openComposer({})
}, [store])
const onSoftReset = React.useCallback(() => {
if (isScreenFocused) {
onScrollToTop()
algoFeed.refresh()
}
}, [isScreenFocused, onScrollToTop, algoFeed])
// fires when page within screen is activated/deactivated
React.useEffect(() => {
if (!isScreenFocused) {
return
}
const softResetSub = store.onScreenSoftReset(onSoftReset)
return () => {
softResetSub.remove()
}
}, [store, onSoftReset, isScreenFocused])
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'feedHeaderDropdownViewAuthorBtn',
label: 'View author',
onPress: onPressViewAuthor,
icon: {
ios: {
name: 'person',
},
android: '',
web: ['far', 'user'],
},
},
{
testID: 'feedHeaderDropdownToggleSavedBtn',
label: currentFeed?.isSaved
@ -260,17 +294,51 @@ export const CustomFeedScreenInner = observer(
},
]
return items
}, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
}, [
currentFeed?.isSaved,
onToggleSaved,
onPressReport,
onPressShare,
onPressViewAuthor,
])
const renderHeaderBtns = React.useCallback(() => {
const renderEmptyState = React.useCallback(() => {
return (
<View style={styles.headerBtns}>
<View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}>
<EmptyState icon="feed" message="This feed is empty!" />
</View>
)
}, [pal.border])
return (
<View style={s.hContentRegion}>
<SimpleViewHeader
showBackButton={isMobile}
style={
!isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
}>
<Text type="title-lg" style={styles.headerText} numberOfLines={1}>
{currentFeed ? (
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={currentFeed?.displayName || ''}
onPress={() => store.emitScreenSoftReset()}
/>
) : (
'Loading...'
)}
</Text>
{currentFeed ? (
<>
<Button
type="default-light"
testID="toggleLikeBtn"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}>
onPress={onToggleLiked}
style={styles.headerBtn}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={19} style={styles.liked} />
) : (
@ -284,7 +352,8 @@ export const CustomFeedScreenInner = observer(
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}>
onPress={onTogglePinned}
style={styles.headerBtn}>
<FontAwesomeIcon
icon="thumb-tack"
size={17}
@ -292,26 +361,30 @@ export const CustomFeedScreenInner = observer(
style={styles.top1}
/>
</Button>
) : undefined}
{!currentFeed?.isSaved ? (
) : (
<Button
type="default-light"
type="inverted"
onPress={onToggleSaved}
accessibilityLabel="Add to my feeds"
accessibilityHint=""
style={styles.headerAddBtn}>
<FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} />
<Text type="xl-medium" style={pal.link}>
Add to My Feeds
<FontAwesomeIcon
icon="plus"
color={palInverted.colors.text}
size={19}
/>
<Text type="button" style={palInverted.text}>
Add{!isMobile && ' to My Feeds'}
</Text>
</Button>
)}
</>
) : null}
<NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}>
<View
style={{
paddingLeft: currentFeed?.isSaved ? 12 : 6,
paddingRight: 12,
paddingVertical: 8,
paddingLeft: 12,
paddingRight: isMobile ? 12 : 0,
}}>
<FontAwesomeIcon
icon="ellipsis"
@ -320,200 +393,19 @@ export const CustomFeedScreenInner = observer(
/>
</View>
</NativeDropdown>
</View>
)
}, [
pal,
currentFeed?.isSaved,
currentFeed?.isLiked,
isPinned,
onToggleSaved,
onTogglePinned,
onToggleLiked,
dropdownItems,
])
const renderListHeaderComponent = React.useCallback(() => {
return (
<>
<View style={[styles.header, pal.border]}>
<View style={s.flex1}>
<Text
testID="feedName"
type="title-xl"
style={[pal.text, s.bold]}>
{currentFeed?.displayName}
</Text>
{currentFeed && (
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
by{' '}
{currentFeed.data.creator.did === store.me.did ? (
'you'
) : (
<TextLink
text={sanitizeHandle(
currentFeed.data.creator.handle,
'@',
)}
href={makeProfileLink(currentFeed.data.creator)}
style={[pal.textLight]}
/>
)}
</Text>
)}
{isTabletOrDesktop && (
<View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
<Button
type={currentFeed?.isSaved ? 'default' : 'inverted'}
onPress={onToggleSaved}
accessibilityLabel={
currentFeed?.isSaved
? 'Unsave this feed'
: 'Save this feed'
}
accessibilityHint=""
label={
currentFeed?.isSaved
? 'Remove from My Feeds'
: 'Add to My Feeds'
}
/>
<Button
type="default"
accessibilityLabel={
isPinned ? 'Unpin this feed' : 'Pin this feed'
}
accessibilityHint=""
onPress={onTogglePinned}>
<FontAwesomeIcon
icon="thumb-tack"
size={15}
color={isPinned ? colors.blue3 : pal.colors.icon}
style={styles.top2}
/>
</Button>
<Button
type="default"
accessibilityLabel="Like this feed"
accessibilityHint=""
onPress={onToggleLiked}>
{currentFeed?.isLiked ? (
<HeartIconSolid size={18} style={styles.liked} />
) : (
<HeartIcon strokeWidth={3} size={18} style={pal.icon} />
)}
</Button>
<Button
type="default"
accessibilityLabel="Share this feed"
accessibilityHint=""
onPress={onPressShare}>
<FontAwesomeIcon
icon="share"
size={18}
color={pal.colors.icon}
/>
</Button>
<Button
type="default"
accessibilityLabel="Report this feed"
accessibilityHint=""
onPress={onPressReport}>
<FontAwesomeIcon
icon="circle-exclamation"
size={18}
color={pal.colors.icon}
/>
</Button>
</View>
)}
</View>
<View>
<UserAvatar
type="algo"
avatar={currentFeed?.data.avatar}
size={64}
/>
</View>
</View>
<View style={styles.headerDetails}>
{currentFeed?.data.description ? (
<Text style={[pal.text, s.mb10]} numberOfLines={6}>
{currentFeed.data.description}
</Text>
) : null}
<View style={styles.headerDetailsFooter}>
{currentFeed ? (
<TextLink
type="md-medium"
style={pal.textLight}
href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`}
text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
currentFeed?.data.likeCount || 0,
'user',
)}`}
/>
) : null}
</View>
</View>
<View
style={[
styles.fakeSelector,
{
paddingHorizontal: isTabletOrDesktop ? 16 : 6,
},
pal.border,
]}>
<View
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
<Text type="md-medium" style={[pal.text]}>
Feed
</Text>
</View>
</View>
</>
)
}, [
pal,
currentFeed,
store.me.did,
onToggleSaved,
onToggleLiked,
onPressShare,
handleOrDid,
onPressReport,
rkey,
isPinned,
onTogglePinned,
isTabletOrDesktop,
])
const renderEmptyState = React.useCallback(() => {
return (
<View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}>
<EmptyState icon="feed" message="This feed is empty!" />
</View>
)
}, [pal.border])
return (
<View style={s.hContentRegion}>
{!isTabletOrDesktop && (
<ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} />
)}
</SimpleViewHeader>
<Feed
scrollElRef={scrollElRef}
feed={algoFeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
ListHeaderComponent={renderListHeaderComponent}
renderEmptyState={renderEmptyState}
extraData={[uri, isPinned]}
style={!isTabletOrDesktop ? {flex: 1} : undefined}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onScrollToTop}
onPress={onSoftReset}
label="Scroll to top"
showIndicator={false}
/>
@ -540,36 +432,19 @@ const styles = StyleSheet.create({
paddingBottom: 16,
borderTopWidth: 1,
},
headerBtns: {
flexDirection: 'row',
alignItems: 'center',
headerText: {
flex: 1,
fontWeight: 'bold',
},
headerBtnsDesktop: {
marginTop: 8,
gap: 4,
headerBtn: {
paddingVertical: 0,
},
headerAddBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingLeft: 4,
},
headerDetails: {
paddingHorizontal: 16,
paddingBottom: 16,
},
headerDetailsFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
fakeSelector: {
flexDirection: 'row',
},
fakeSelectorItem: {
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 3,
paddingVertical: 4,
paddingLeft: 10,
},
liked: {
color: colors.red3,

View File

@ -1,157 +0,0 @@
import React from 'react'
import {RefreshControl, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {useStores} from 'state/index'
import {FeedsDiscoveryModel} from 'state/models/discovery/feeds'
import {CenteredView, FlatList} from 'view/com/util/Views'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
import debounce from 'lodash.debounce'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
export const DiscoverFeedsScreen = withAuthRequired(
observer(function DiscoverFeedsScreenImpl({}: Props) {
const store = useStores()
const pal = usePalette('default')
const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
const {isTabletOrDesktop} = useWebMediaQueries()
// search stuff
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const debouncedSearchFeeds = React.useMemo(
() => debounce(q => feeds.search(q), 500), // debounce for 500ms
[feeds],
)
const onChangeQuery = React.useCallback(
(text: string) => {
setQuery(text)
if (text.length > 1) {
debouncedSearchFeeds(text)
} else {
feeds.refresh()
}
},
[debouncedSearchFeeds, feeds],
)
const onPressClearQuery = React.useCallback(() => {
setQuery('')
feeds.refresh()
}, [feeds])
const onPressCancelSearch = React.useCallback(() => {
setIsInputFocused(false)
setQuery('')
feeds.refresh()
}, [feeds])
const onSubmitQuery = React.useCallback(() => {
debouncedSearchFeeds(query)
debouncedSearchFeeds.flush()
}, [debouncedSearchFeeds, query])
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
if (!feeds.hasLoaded) {
feeds.refresh()
}
}, [store, feeds]),
)
const onRefresh = React.useCallback(() => {
feeds.refresh()
}, [feeds])
const renderListEmptyComponent = () => {
return (
<View style={styles.empty}>
<Text type="lg" style={pal.textLight}>
{feeds.isLoading
? isTabletOrDesktop
? 'Loading...'
: ''
: query
? `No results found for "${query}"`
: `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
</Text>
</View>
)
}
const renderItem = React.useCallback(
({item}: {item: CustomFeedModel}) => (
<CustomFeed
key={item.data.uri}
item={item}
showSaveBtn
showDescription
showLikes
/>
),
[],
)
return (
<CenteredView style={[styles.container, pal.view]}>
<View
style={[isTabletOrDesktop && styles.containerDesktop, pal.border]}>
<ViewHeader title="Discover Feeds" showOnDesktop />
</View>
<HeaderWithInput
isInputFocused={isInputFocused}
query={query}
setIsInputFocused={setIsInputFocused}
onChangeQuery={onChangeQuery}
onPressClearQuery={onPressClearQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
showMenu={false}
/>
<FlatList
style={[!isTabletOrDesktop && s.flex1]}
data={feeds.feeds}
keyExtractor={item => item.data.uri}
contentContainerStyle={styles.contentContainer}
refreshControl={
<RefreshControl
refreshing={feeds.isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
renderItem={renderItem}
initialNumToRender={10}
ListEmptyComponent={renderListEmptyComponent}
onEndReached={() => feeds.loadMore()}
extraData={feeds.isLoading}
/>
</CenteredView>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingBottom: 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
empty: {
paddingHorizontal: 16,
paddingTop: 10,
},
})

View File

@ -1,90 +1,72 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import isEqual from 'lodash.isequal'
import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '@atproto/api'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FlatList} from 'view/com/util/Views'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Link} from 'view/com/util/Link'
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
import {MultiFeed} from 'view/com/posts/MultiFeed'
import {usePalette} from 'lib/hooks/usePalette'
import {useTimer} from 'lib/hooks/useTimer'
import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles'
const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds
const MOBILE_HEADER_OFFSET = 40
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import debounce from 'lodash.debounce'
import {Text} from 'view/com/util/text/Text'
import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds'
import {FlatList} from 'view/com/util/Views'
import {useFocusEffect} from '@react-navigation/native'
import {CustomFeed} from 'view/com/feeds/CustomFeed'
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired(
observer<Props>(function FeedsScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const flatListRef = React.useRef<FlatList>(null)
const multifeed = React.useMemo<PostsMultiFeedModel>(
() => new PostsMultiFeedModel(store),
[store],
const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store])
const [query, setQuery] = React.useState<string>('')
const debouncedSearchFeeds = React.useMemo(
() => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms
[myFeeds],
)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const [loadPromptVisible, setLoadPromptVisible] = React.useState(false)
const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => {
setLoadPromptVisible(true)
})
const onSoftReset = React.useCallback(() => {
flatListRef.current?.scrollToOffset({offset: 0})
multifeed.loadLatest()
resetPromptTimer()
setLoadPromptVisible(false)
resetMainScroll()
}, [
flatListRef,
resetMainScroll,
multifeed,
resetPromptTimer,
setLoadPromptVisible,
])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const multifeedCleanup = multifeed.registerListeners()
const cleanup = () => {
softResetSub.remove()
multifeedCleanup()
}
store.shell.setMinimalShellMode(false)
return cleanup
}, [store, multifeed, onSoftReset]),
myFeeds.setup()
}, [store.shell, myFeeds]),
)
React.useEffect(() => {
if (
isEqual(
multifeed.feedInfos.map(f => f.uri),
store.me.savedFeeds.all.map(f => f.uri),
)
) {
// no changes
return
}
multifeed.refresh()
}, [multifeed, store.me.savedFeeds.all])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
const onChangeQuery = React.useCallback(
(text: string) => {
setQuery(text)
if (text.length > 1) {
debouncedSearchFeeds(text)
} else {
myFeeds.discovery.refresh()
}
},
[debouncedSearchFeeds, myFeeds.discovery],
)
const onPressCancelSearch = React.useCallback(() => {
setQuery('')
myFeeds.discovery.refresh()
}, [myFeeds])
const onSubmitQuery = React.useCallback(() => {
debouncedSearchFeeds(query)
debouncedSearchFeeds.flush()
}, [debouncedSearchFeeds, query])
const renderHeaderBtn = React.useCallback(() => {
return (
@ -99,30 +81,150 @@ export const FeedsScreen = withAuthRequired(
)
}, [pal])
const onRefresh = React.useCallback(() => {
myFeeds.refresh()
}, [myFeeds])
const renderItem = React.useCallback(
({item}: {item: MyFeedsItem}) => {
if (item.type === 'discover-feeds-loading') {
return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'spinner') {
return (
<View style={[pal.view, styles.container]}>
<MultiFeed
scrollElRef={flatListRef}
multifeed={multifeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
headerOffset={isMobile ? MOBILE_HEADER_OFFSET : undefined}
<View style={s.p10}>
<ActivityIndicator />
</View>
)
} else if (item.type === 'error') {
return <ErrorMessage message={item.error} />
} else if (item.type === 'saved-feeds-header') {
if (!isMobile) {
return (
<View
style={[
pal.view,
styles.header,
pal.border,
{
borderBottomWidth: 1,
},
]}>
<Text type="title-lg" style={[pal.text, s.bold]}>
My Feeds
</Text>
<Link href="/settings/saved-feeds">
<CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
</Link>
</View>
)
}
return <View />
} else if (item.type === 'saved-feed') {
return (
<SavedFeed
uri={item.feed.uri}
avatar={item.feed.data.avatar}
displayName={item.feed.displayName}
/>
{isMobile && (
<ViewHeader
title="My Feeds"
canGoBack={false}
hideOnScroll
renderButton={renderHeaderBtn}
)
} else if (item.type === 'discover-feeds-header') {
return (
<>
<View
style={[
pal.view,
styles.header,
{
marginTop: 16,
paddingLeft: isMobile ? 12 : undefined,
paddingRight: 10,
paddingBottom: isMobile ? 6 : undefined,
},
]}>
<Text type="title-lg" style={[pal.text, s.bold]}>
Discover new feeds
</Text>
{!isMobile && (
<SearchInput
query={query}
onChangeQuery={onChangeQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
style={{flex: 1, maxWidth: 250}}
/>
)}
{isScrolledDown || loadPromptVisible ? (
<LoadLatestBtn
onPress={onSoftReset}
label="Load latest posts"
showIndicator={loadPromptVisible}
</View>
{isMobile && (
<View style={{paddingHorizontal: 8, paddingBottom: 10}}>
<SearchInput
query={query}
onChangeQuery={onChangeQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
/>
</View>
)}
</>
)
} else if (item.type === 'discover-feed') {
return (
<CustomFeed
item={item.feed}
showSaveBtn
showDescription
showLikes
/>
)
} else if (item.type === 'discover-feeds-no-results') {
return (
<View
style={{
paddingHorizontal: 16,
paddingTop: 10,
paddingBottom: '150%',
}}>
<Text type="lg" style={pal.textLight}>
No results found for "{query}"
</Text>
</View>
)
}
return null
},
[isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery],
)
return (
<View style={[pal.view, styles.container]}>
{isMobile && (
<ViewHeader
title="Feeds"
canGoBack={false}
renderButton={renderHeaderBtn}
showBorder
/>
)}
<FlatList
style={[!isTabletOrDesktop && s.flex1, styles.list]}
data={myFeeds.items}
keyExtractor={item => item._reactKey}
contentContainerStyle={styles.contentContainer}
refreshControl={
<RefreshControl
refreshing={myFeeds.isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
renderItem={renderItem}
initialNumToRender={10}
onEndReached={() => myFeeds.loadMore()}
extraData={myFeeds.isLoading}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
@ -136,8 +238,76 @@ export const FeedsScreen = withAuthRequired(
}),
)
function SavedFeed({
uri,
avatar,
displayName,
}: {
uri: string
avatar: string | undefined
displayName: string
}) {
const pal = usePalette('default')
const urip = new AtUri(uri)
const href = `/profile/${urip.hostname}/feed/${urip.rkey}`
const {isMobile} = useWebMediaQueries()
return (
<Link
testID={`saved-feed-${displayName}`}
href={href}
style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
hoverStyle={pal.viewLight}
accessibilityLabel={displayName}
accessibilityHint=""
asAnchor
anchorNoUnderline>
<UserAvatar type="algo" size={28} avatar={avatar} />
<Text
type={isMobile ? 'lg' : 'lg-medium'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{displayName}
</Text>
{isMobile && (
<FontAwesomeIcon
icon="chevron-right"
size={14}
style={pal.textLight as FontAwesomeIconStyle}
/>
)}
</Link>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
height: '100%',
},
contentContainer: {
paddingBottom: 100,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
paddingHorizontal: 16,
paddingVertical: 12,
},
savedFeed: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
gap: 12,
borderBottomWidth: 1,
},
savedFeedMobile: {
paddingVertical: 10,
},
})

View File

@ -1,6 +1,8 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
@ -8,6 +10,7 @@ import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {PostsFeedModel} from 'state/models/feeds/posts'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {TextLink} from 'view/com/util/Link'
import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
@ -16,14 +19,16 @@ import {FeedsTabBar} from '../com/pager/FeedsTabBar'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {FAB} from '../com/util/fab/FAB'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from 'lib/icons'
const HEADER_OFFSET_MOBILE = 78
const HEADER_OFFSET_DESKTOP = 50
const HEADER_OFFSET_TABLET = 50
const HEADER_OFFSET_DESKTOP = 0
const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
@ -154,17 +159,23 @@ const FeedPage = observer(function FeedPageImpl({
renderEmptyState?: () => JSX.Element
}) {
const store = useStores()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {isMobile, isTablet, isDesktop} = useWebMediaQueries()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
const {screen, track} = useAnalytics()
const [headerOffset, setHeaderOffset] = React.useState(
isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
isMobile
? HEADER_OFFSET_MOBILE
: isTablet
? HEADER_OFFSET_TABLET
: HEADER_OFFSET_DESKTOP,
)
const scrollElRef = React.useRef<FlatList>(null)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isScreenFocused = useIsFocused()
const hasNew = feed.hasNewLatest && !feed.isRefreshing
React.useEffect(() => {
// called on first load
@ -205,8 +216,14 @@ const FeedPage = observer(function FeedPageImpl({
// listens for resize events
React.useEffect(() => {
setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
}, [isMobile])
setHeaderOffset(
isMobile
? HEADER_OFFSET_MOBILE
: isTablet
? HEADER_OFFSET_TABLET
: HEADER_OFFSET_DESKTOP,
)
}, [isMobile, isTablet])
// fires when page within screen is activated/deactivated
// - check for latest
@ -222,9 +239,6 @@ const FeedPage = observer(function FeedPageImpl({
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
feed.checkForLatest()
if (feed.hasContent) {
feed.update()
}
return () => {
clearInterval(pollInterval)
@ -247,7 +261,59 @@ const FeedPage = observer(function FeedPageImpl({
feed.refresh()
}, [feed, scrollToTop])
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
{store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={() => store.emitScreenSoftReset()}
/>
<TextLink
type="title-lg"
href="/settings/home-feed"
style={{fontWeight: 'bold'}}
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
</View>
)
}
return <></>
}, [isDesktop, pal, store, hasNew])
return (
<View testID={testID} style={s.h100pct}>
<Feed
@ -259,6 +325,7 @@ const FeedPage = observer(function FeedPageImpl({
onScroll={onMainScroll}
scrollEventThrottle={100}
renderEmptyState={renderEmptyState}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset}
/>
{(isScrolledDown || hasNew) && (

View File

@ -9,12 +9,15 @@ import {
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed'
import {TextLink} from 'view/com/util/Link'
import {InvitedUsers} from '../com/notifications/InvitedUsers'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {isWeb} from 'platform/detection'
@ -29,6 +32,12 @@ export const NotificationsScreen = withAuthRequired(
useOnMainScroll(store)
const scrollElRef = React.useRef<FlatList>(null)
const {screen} = useAnalytics()
const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries()
const hasNew =
store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing
// event handlers
// =
@ -88,9 +97,48 @@ export const NotificationsScreen = withAuthRequired(
),
)
const hasNew =
store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/notifications"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
Notifications{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={() => store.emitScreenSoftReset()}
/>
</View>
)
}
return <></>
}, [isDesktop, pal, store, hasNew])
return (
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired(
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn

View File

@ -19,14 +19,7 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold)
return (
<View style={[s.mt10, !enabled && styles.dimmed]}>
<Text type="xs" style={pal.text}>
{value === 0
? `Show all replies`
: `Show replies with at least ${value} ${
value > 1 ? `likes` : `like`
}`}
</Text>
<View style={[!enabled && styles.dimmed]}>
<Slider
value={value}
onValueChange={(v: number | number[]) => {
@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
disabled={!enabled}
thumbTintColor={colors.blue3}
/>
<Text type="xs" style={pal.text}>
{value === 0
? `Show all replies`
: `Show replies with at least ${value} ${
value > 1 ? `likes` : `like`
}`}
</Text>
</View>
)
}
@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
Show Replies
</Text>
<Text style={[pal.text, s.pb10]}>
Adjust the number of likes a reply must have to be shown in your
feed.
Set this setting to "No" to hide all replies from your feed.
</Text>
<ToggleButton
type="default-light"
@ -88,7 +87,36 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
isSelected={store.preferences.homeFeedRepliesEnabled}
onPress={store.preferences.toggleHomeFeedRepliesEnabled}
/>
</View>
<View
style={[
pal.viewLight,
styles.card,
!store.preferences.homeFeedRepliesEnabled && styles.dimmed,
]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
Reply Filters
</Text>
<Text style={[pal.text, s.pb10]}>
Enable this setting to only see replies between people you follow.
</Text>
<ToggleButton
type="default-light"
label="Followed users only"
isSelected={
store.preferences.homeFeedRepliesByFollowedOnlyEnabled
}
onPress={
store.preferences.homeFeedRepliesEnabled
? store.preferences.toggleHomeFeedRepliesByFollowedOnlyEnabled
: undefined
}
style={[s.mb10]}
/>
<Text style={[pal.text]}>
Adjust the number of likes a reply must have to be shown in your
feed.
</Text>
<RepliesThresholdInput
enabled={store.preferences.homeFeedRepliesEnabled}
/>
@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
onPress={store.preferences.toggleHomeFeedQuotePostsEnabled}
/>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
Show Posts from My Feeds (Experimental)
</Text>
<Text style={[pal.text, s.pb10]}>
Set this setting to "Yes" to show samples of your saved feeds in
your following feed.
</Text>
<ToggleButton
type="default-light"
label={store.preferences.homeFeedMergeFeedEnabled ? 'Yes' : 'No'}
isSelected={store.preferences.homeFeedMergeFeedEnabled}
onPress={store.preferences.toggleHomeFeedMergeFeedEnabled}
/>
</View>
</View>
</ScrollView>

View File

@ -69,9 +69,7 @@ export const ProfileScreen = withAuthRequired(
let aborted = false
store.shell.setMinimalShellMode(false)
const feedCleanup = uiState.feed.registerListeners()
if (hasSetup) {
uiState.update()
} else {
if (!hasSetup) {
uiState.setup().then(() => {
if (aborted) {
return

View File

@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired(
return (
<>
<View style={[styles.footerLinks, pal.border]}>
<Link style={styles.footerLink} href="/search/feeds">
<Link style={styles.footerLink} href="/feeds">
<FontAwesomeIcon
icon="search"
size={18}

View File

@ -40,7 +40,7 @@ import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {HandIcon} from 'lib/icons'
import {HandIcon, HashtagIcon} from 'lib/icons'
import {formatCount} from 'view/com/util/numeric/format'
import Clipboard from '@react-native-clipboard/clipboard'
import {reset as resetNavigation} from '../../Navigation'
@ -423,17 +423,14 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity
testID="savedFeedsBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
accessibilityHint="Saved Feeds"
accessibilityHint="My Saved Feeds"
accessibilityLabel="Opens screen with all saved feeds"
onPress={onPressSavedFeeds}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="satellite-dish"
style={pal.text as FontAwesomeIconStyle}
/>
<HashtagIcon style={pal.text} size={18} strokeWidth={3} />
</View>
<Text type="lg" style={pal.text}>
Saved Feeds
My Saved Feeds
</Text>
</TouchableOpacity>
<TouchableOpacity

View File

@ -28,8 +28,7 @@ import {
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
UserIconSolid,
SatelliteDishIcon,
SatelliteDishIconSolid,
HashtagIcon,
HandIcon,
} from 'lib/icons'
import {UserAvatar} from 'view/com/util/UserAvatar'
@ -258,21 +257,21 @@ export const DrawerContent = observer(function DrawerContentImpl() {
<MenuItem
icon={
isAtFeeds ? (
<SatelliteDishIconSolid
strokeWidth={1.5}
<HashtagIcon
strokeWidth={3}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
) : (
<SatelliteDishIcon
strokeWidth={1.5}
<HashtagIcon
strokeWidth={2}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
)
}
label="My Feeds"
accessibilityLabel="My Feeds"
label="Feeds"
accessibilityLabel="Feeds"
accessibilityHint=""
onPress={onPressMyFeeds}
/>

View File

@ -18,8 +18,7 @@ import {
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
SatelliteDishIcon,
SatelliteDishIconSolid,
HashtagIcon,
BellIcon,
BellIconSolid,
} from 'lib/icons'
@ -134,16 +133,16 @@ export const BottomBar = observer(function BottomBarImpl({
testID="bottomBarFeedsBtn"
icon={
isAtFeeds ? (
<SatelliteDishIconSolid
size={25}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
strokeWidth={1.8}
<HashtagIcon
size={24}
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
strokeWidth={4}
/>
) : (
<SatelliteDishIcon
size={25}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
strokeWidth={1.8}
<HashtagIcon
size={24}
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
strokeWidth={2.25}
/>
)
}

View File

@ -49,6 +49,9 @@ export const styles = StyleSheet.create({
homeIcon: {
top: 0,
},
feedsIcon: {
top: -2,
},
searchIcon: {
top: -2,
},

View File

@ -15,8 +15,7 @@ import {
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
SatelliteDishIcon,
SatelliteDishIconSolid,
HashtagIcon,
UserIcon,
UserIconSolid,
} from 'lib/icons'
@ -68,12 +67,11 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() {
</NavItem>
<NavItem routeName="Feeds" href="/feeds">
{({isActive}) => {
const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon
return (
<Icon
size={25}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
strokeWidth={1.8}
<HashtagIcon
size={22}
style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
strokeWidth={isActive ? 4 : 2.5}
/>
)
}}

View File

@ -0,0 +1,92 @@
import React from 'react'
import {View, StyleSheet} from 'react-native'
import {useNavigationState} from '@react-navigation/native'
import {AtUri} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {TextLink} from 'view/com/util/Link'
import {getCurrentRoute} from 'lib/routes/helpers'
export const DesktopFeeds = observer(function DesktopFeeds() {
const store = useStores()
const pal = usePalette('default')
const route = useNavigationState(state => {
if (!state) {
return {name: 'Home'}
}
return getCurrentRoute(state)
})
return (
<View style={[styles.container, pal.view, pal.border]}>
<FeedItem href="/" title="Following" current={route.name === 'Home'} />
{store.me.savedFeeds.pinned.map(feed => {
try {
const {hostname, rkey} = new AtUri(feed.uri)
const href = `/profile/${hostname}/feed/${rkey}`
const params = route.params as Record<string, string>
return (
<FeedItem
key={feed.uri}
href={href}
title={feed.displayName}
current={
route.name === 'CustomFeed' &&
params.name === hostname &&
params.rkey === rkey
}
/>
)
} catch {
return null
}
})}
<View style={{paddingTop: 8, paddingBottom: 6}}>
<TextLink
type="lg"
href="/feeds"
text="More feeds"
style={[pal.link]}
/>
</View>
</View>
)
})
function FeedItem({
title,
href,
current,
}: {
title: string
href: string
current: boolean
}) {
const pal = usePalette('default')
return (
<View style={{paddingVertical: 6}}>
<TextLink
type="xl"
href={href}
text={title}
style={[
current ? pal.text : pal.textLight,
{letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},
]}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'relative',
width: 300,
paddingHorizontal: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
paddingVertical: 18,
},
})

View File

@ -32,8 +32,7 @@ import {
CogIconSolid,
ComposeIcon2,
HandIcon,
SatelliteDishIcon,
SatelliteDishIconSolid,
HashtagIcon,
} from 'lib/icons'
import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
@ -272,20 +271,20 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
<NavItem
href="/feeds"
icon={
<SatelliteDishIcon
strokeWidth={1.75}
<HashtagIcon
strokeWidth={2.25}
style={pal.text as FontAwesomeIconStyle}
size={isDesktop ? 24 : 28}
/>
}
iconFilled={
<SatelliteDishIconSolid
strokeWidth={1.75}
<HashtagIcon
strokeWidth={2.5}
style={pal.text as FontAwesomeIconStyle}
size={isDesktop ? 24 : 28}
/>
}
label="My Feeds"
label="Feeds"
/>
<NavItem
href="/notifications"

View File

@ -4,6 +4,7 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {DesktopSearch} from './Search'
import {DesktopFeeds} from './Feeds'
import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
@ -26,6 +27,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
return (
<View style={[styles.rightNav, pal.view]}>
{store.session.hasSession && <DesktopSearch />}
{store.session.hasSession && <DesktopFeeds />}
<View style={styles.message}>
{store.session.isSandbox ? (
<View style={[palError.view, styles.messageLine, s.p10]}>
@ -126,7 +128,7 @@ const styles = StyleSheet.create({
},
message: {
marginTop: 20,
paddingVertical: 18,
paddingHorizontal: 10,
},
messageLine: {
@ -134,7 +136,6 @@ const styles = StyleSheet.create({
},
inviteCodes: {
marginTop: 12,
borderTopWidth: 1,
paddingHorizontal: 16,
paddingVertical: 12,

View File

@ -113,6 +113,7 @@ const styles = StyleSheet.create({
container: {
position: 'relative',
width: 300,
paddingBottom: 18,
},
search: {
paddingHorizontal: 16,

View File

@ -6418,6 +6418,13 @@
dependencies:
"@types/lodash" "*"
"@types/lodash.random@^3.2.7":
version "3.2.7"
resolved "https://registry.yarnpkg.com/@types/lodash.random/-/lodash.random-3.2.7.tgz#3100a1b7956ce86ab5adcce2e7b305412b98e3bf"
integrity sha512-gFKkVgWYi1q7RFJ+QNTzaRprdhVIZLpZd6C3MTNehKcujMn9SyFUqf2fTBOmvIYXqNk0RpwfbdOwHf0GnEQB0g==
dependencies:
"@types/lodash" "*"
"@types/lodash.samplesize@^4.2.7":
version "4.2.7"
resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f"
@ -13886,6 +13893,11 @@ lodash.once@^4.0.0, lodash.once@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash.random@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/lodash.random/-/lodash.random-3.2.0.tgz#96e24e763333199130d2c9e2fd57f91703cc262d"
integrity sha512-A6Vn7teN0+qSnhOsE8yx2bGowCS1G7D9e5abq8VhwOP98YHS/KrGMf43yYxA05lvcvloT+W9Z2ffkSajFTcPUA==
lodash.samplesize@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9"
@ -16855,10 +16867,10 @@ react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-error-overlay@^6.0.11:
version "6.0.11"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-error-overlay@6.0.9, react-error-overlay@^6.0.11:
version "6.0.9"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
react-freeze@^1.0.0:
version "1.0.3"