Store/sync pinned feeds on the server

zio/stable
Paul Frazee 2023-05-18 11:51:25 -05:00
parent d88c27a419
commit 7691fe4f48
8 changed files with 278 additions and 240 deletions

View File

@ -69,7 +69,6 @@ export class MeModel {
displayName: this.displayName, displayName: this.displayName,
description: this.description, description: this.description,
avatar: this.avatar, avatar: this.avatar,
savedFeeds: this.savedFeeds.serialize(),
} }
} }
@ -91,9 +90,6 @@ export class MeModel {
if (hasProp(v, 'avatar') && typeof v.avatar === 'string') { if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
avatar = v.avatar avatar = v.avatar
} }
if (hasProp(v, 'savedFeeds') && isObj(v.savedFeeds)) {
this.savedFeeds.hydrate(v.savedFeeds)
}
if (did && handle) { if (did && handle) {
this.did = did this.did = did
this.handle = handle this.handle = handle
@ -118,7 +114,7 @@ export class MeModel {
/* dont await */ this.notifications.setup().catch(e => { /* dont await */ this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e) this.rootStore.log.error('Failed to setup notifications model', e)
}) })
/* dont await */ this.savedFeeds.refresh() /* dont await */ this.savedFeeds.refresh(true)
this.rootStore.emitSessionLoaded() this.rootStore.emitSessionLoaded()
await this.fetchInviteCodes() await this.fetchInviteCodes()
await this.fetchAppPasswords() await this.fetchAppPasswords()
@ -128,6 +124,7 @@ export class MeModel {
} }
async updateIfNeeded() { async updateIfNeeded() {
/* dont await */ this.savedFeeds.refresh(true)
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
this.rootStore.log.debug('Updating me profile information') this.rootStore.log.debug('Updating me profile information')
this.lastProfileStateUpdate = Date.now() this.lastProfileStateUpdate = Date.now()

View File

@ -25,6 +25,7 @@ const LABEL_GROUPS = [
'spam', 'spam',
'impersonation', 'impersonation',
] ]
const VISIBILITY_VALUES = ['show', 'warn', 'hide']
export class LabelPreferencesModel { export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide' nsfw: LabelPreference = 'hide'
@ -45,6 +46,7 @@ export class PreferencesModel {
contentLanguages: string[] = contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || [] deviceLocales?.map?.(locale => locale.languageCode) || []
contentLabels = new LabelPreferencesModel() contentLabels = new LabelPreferencesModel()
pinnedFeeds: string[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {}, {autoBind: true}) makeAutoObservable(this, {}, {autoBind: true})
@ -54,6 +56,7 @@ export class PreferencesModel {
return { return {
contentLanguages: this.contentLanguages, contentLanguages: this.contentLanguages,
contentLabels: this.contentLabels, contentLabels: this.contentLabels,
pinnedFeeds: this.pinnedFeeds,
} }
} }
@ -72,6 +75,13 @@ export class PreferencesModel {
// default to the device languages // default to the device languages
this.contentLanguages = deviceLocales.map(locale => locale.languageCode) this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
} }
if (
hasProp(v, 'pinnedFeeds') &&
Array.isArray(v.pinnedFeeds) &&
typeof v.pinnedFeeds.every(item => typeof item === 'string')
) {
this.pinnedFeeds = v.pinnedFeeds
}
} }
} }
@ -88,9 +98,18 @@ export class PreferencesModel {
AppBskyActorDefs.isContentLabelPref(pref) && AppBskyActorDefs.isContentLabelPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success AppBskyActorDefs.validateAdultContentPref(pref).success
) { ) {
if (LABEL_GROUPS.includes(pref.label)) { if (
this.contentLabels[pref.label] = pref.visibility LABEL_GROUPS.includes(pref.label) &&
VISIBILITY_VALUES.includes(pref.visibility)
) {
this.contentLabels[pref.label as keyof LabelPreferencesModel] =
pref.visibility as LabelPreference
} }
} else if (
AppBskyActorDefs.isPinnedFeedsPref(pref) &&
AppBskyActorDefs.validatePinnedFeedsPref(pref).success
) {
this.pinnedFeeds = pref.feeds
} }
} }
}) })
@ -200,4 +219,39 @@ export class PreferencesModel {
} }
return res return res
} }
async setPinnedFeeds(v: string[]) {
const old = this.pinnedFeeds
this.pinnedFeeds = v
try {
await this.update((prefs: AppBskyActorDefs.Preferences) => {
const existing = prefs.find(
pref =>
AppBskyActorDefs.isPinnedFeedsPref(pref) &&
AppBskyActorDefs.validatePinnedFeedsPref(pref).success,
)
if (existing) {
existing.feeds = v
} else {
prefs.push({
$type: 'app.bsky.actor.defs#pinnedFeedsPref',
feeds: v,
})
}
})
} catch (e) {
runInAction(() => {
this.pinnedFeeds = old
})
throw e
}
}
async addPinnedFeed(v: string) {
return this.setPinnedFeeds([...this.pinnedFeeds, v])
}
async removePinnedFeed(v: string) {
return this.setPinnedFeeds(this.pinnedFeeds.filter(uri => uri !== v))
}
} }

View File

@ -1,12 +1,11 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyFeedGetSavedFeeds as GetSavedFeeds} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed' import {CustomFeedModel} from '../feeds/custom-feed'
import {hasProp, isObj} from 'lib/type-guards'
const PAGE_SIZE = 30 const PAGE_SIZE = 100
export class SavedFeedsModel { export class SavedFeedsModel {
// state // state
@ -14,12 +13,9 @@ export class SavedFeedsModel {
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
error = '' error = ''
hasMore = true
loadMoreCursor?: string
// data // data
feeds: CustomFeedModel[] = [] feeds: CustomFeedModel[] = []
pinned: CustomFeedModel[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@ -31,24 +27,6 @@ export class SavedFeedsModel {
) )
} }
serialize() {
return {
pinned: this.pinned.map(f => f.serialize()),
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'pinned')) {
const pinnedSerialized = (v as any).pinned as string[]
const pinnedDeserialized = pinnedSerialized.map(
(s: string) => new CustomFeedModel(this.rootStore, JSON.parse(s)),
)
this.pinned = pinnedDeserialized
}
}
}
get hasContent() { get hasContent() {
return this.feeds.length > 0 return this.feeds.length > 0
} }
@ -61,149 +39,121 @@ export class SavedFeedsModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get numFeeds() { get pinned() {
return this.feeds.length return this.rootStore.preferences.pinnedFeeds
.map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
.filter(Boolean)
} }
get unpinned() { get unpinned() {
return this.feeds.filter( return this.feeds.filter(f => !this.isPinned(f))
f => !this.pinned.find(p => p.data.uri === f.data.uri),
)
}
get feedNames() {
return this.feeds.map(f => f.displayName)
} }
get pinnedFeedNames() { get pinnedFeedNames() {
return this.pinned.map(f => f.displayName) return this.pinned.map(f => f.displayName)
} }
togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) {
this.pinned = [...this.pinned, feed]
} else {
this.removePinnedFeed(feed.data.uri)
}
}
removePinnedFeed(uri: string) {
this.pinned = this.pinned.filter(f => f.data.uri !== uri)
}
reorderPinnedFeeds(temp: CustomFeedModel[]) {
this.pinned = temp.filter(item => this.isPinned(item))
}
isPinned(feed: CustomFeedModel) {
return this.pinned.find(f => f.data.uri === feed.data.uri) ? true : false
}
movePinnedItem(item: CustomFeedModel, direction: 'up' | 'down') {
if (this.pinned.length < 2) {
throw new Error('Array must have at least 2 items')
}
const index = this.pinned.indexOf(item)
if (index === -1) {
throw new Error('Item not found in array')
}
const len = this.pinned.length
runInAction(() => {
if (direction === 'up') {
if (index === 0) {
// Remove the item from the first place and put it at the end
this.pinned.push(this.pinned.shift()!)
} else {
// Swap the item with the one before it
const temp = this.pinned[index]
this.pinned[index] = this.pinned[index - 1]
this.pinned[index - 1] = temp
}
} else if (direction === 'down') {
if (index === len - 1) {
// Remove the item from the last place and put it at the start
this.pinned.unshift(this.pinned.pop()!)
} else {
// Swap the item with the one after it
const temp = this.pinned[index]
this.pinned[index] = this.pinned[index + 1]
this.pinned[index + 1] = temp
}
}
// this.pinned = [...this.pinned]
})
}
// public api // public api
// = // =
async refresh(quietRefresh = false) {
return this.loadMore(true, quietRefresh)
}
clear() { clear() {
this.isLoading = false this.isLoading = false
this.isRefreshing = false this.isRefreshing = false
this.hasLoaded = false this.hasLoaded = false
this.error = '' this.error = ''
this.hasMore = true
this.loadMoreCursor = undefined
this.feeds = [] this.feeds = []
} }
loadMore = bundleAsync( refresh = bundleAsync(async (quietRefresh = false) => {
async (replace: boolean = false, quietRefresh = false) => { this._xLoading(!quietRefresh)
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace && !quietRefresh)
try { try {
let feeds: AppBskyFeedDefs.GeneratorView[] = []
let cursor
for (let i = 0; i < 100; i++) {
const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({ const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor, cursor,
}) })
if (replace) { feeds = feeds.concat(res.data.feeds)
this._replaceAll(res) cursor = res.data.cursor
} else { if (!cursor) {
this._appendAll(res) break
} }
}
runInAction(() => {
this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
})
this._xIdle() this._xIdle()
} catch (e: any) { } catch (e: any) {
this._xIdle(e) this._xIdle(e)
} }
}, })
)
removeFeed(uri: string) { async save(feed: CustomFeedModel) {
this.feeds = this.feeds.filter(f => f.data.uri !== uri)
}
addFeed(algoItem: CustomFeedModel) {
this.feeds.push(new CustomFeedModel(this.rootStore, algoItem.data))
}
async save(algoItem: CustomFeedModel) {
try { try {
await algoItem.save() await feed.save()
this.addFeed(algoItem) runInAction(() => {
this.feeds = [
...this.feeds,
new CustomFeedModel(this.rootStore, feed.data),
]
})
} catch (e: any) { } catch (e: any) {
this.rootStore.log.error('Failed to save feed', e) this.rootStore.log.error('Failed to save feed', e)
} }
} }
async unsave(algoItem: CustomFeedModel) { async unsave(feed: CustomFeedModel) {
const uri = algoItem.uri const uri = feed.uri
try { try {
await algoItem.unsave() if (this.isPinned(feed)) {
this.removeFeed(uri) await this.rootStore.preferences.removePinnedFeed(uri)
this.removePinnedFeed(uri) }
await feed.unsave()
runInAction(() => {
this.feeds = this.feeds.filter(f => f.data.uri !== uri)
})
} catch (e: any) { } catch (e: any) {
this.rootStore.log.error('Failed to unsave feed', e) this.rootStore.log.error('Failed to unsave feed', e)
} }
} }
async togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) {
return this.rootStore.preferences.addPinnedFeed(feed.uri)
} else {
return this.rootStore.preferences.removePinnedFeed(feed.uri)
}
}
async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
return this.rootStore.preferences.setPinnedFeeds(
feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
)
}
isPinned(feed: CustomFeedModel) {
return this.rootStore.preferences.pinnedFeeds.includes(feed.uri)
}
async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri)
if (index === -1) {
return
}
if (direction === 'up' && index !== 0) {
const temp = pinned[index]
pinned[index] = pinned[index - 1]
pinned[index - 1] = temp
} else if (direction === 'down' && index < pinned.length - 1) {
const temp = pinned[index]
pinned[index] = pinned[index + 1]
pinned[index + 1] = temp
}
await this.rootStore.preferences.setPinnedFeeds(pinned)
}
// state transitions // state transitions
// = // =
@ -219,23 +169,7 @@ export class SavedFeedsModel {
this.hasLoaded = true this.hasLoaded = true
this.error = cleanError(err) this.error = cleanError(err)
if (err) { if (err) {
this.rootStore.log.error('Failed to fetch user followers', err) this.rootStore.log.error('Failed to fetch user feeds', err)
}
}
// helper functions
// =
_replaceAll(res: GetSavedFeeds.Response) {
this.feeds = []
this._appendAll(res)
}
_appendAll(res: GetSavedFeeds.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) {
this.feeds.push(new CustomFeedModel(this.rootStore, f))
} }
} }
} }

View File

@ -39,20 +39,30 @@ export const CustomFeed = observer(
const pal = usePalette('default') const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const onToggleSaved = React.useCallback(() => { const onToggleSaved = React.useCallback(async () => {
if (item.data.viewer?.saved) { if (item.data.viewer?.saved) {
store.shell.openModal({ store.shell.openModal({
name: 'confirm', name: 'confirm',
title: 'Remove from my feeds', title: 'Remove from my feeds',
message: `Remove ${item.displayName} from my feeds?`, message: `Remove ${item.displayName} from my feeds?`,
onPressConfirm: () => { onPressConfirm: async () => {
store.me.savedFeeds.unsave(item) try {
await store.me.savedFeeds.unsave(item)
Toast.show('Removed from my feeds') Toast.show('Removed from my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
store.log.error('Failed to unsave feed', {e})
}
}, },
}) })
} else { } else {
store.me.savedFeeds.save(item) try {
await store.me.savedFeeds.save(item)
Toast.show('Added to my feeds') Toast.show('Added to my feeds')
} catch (e) {
Toast.show('There was an issue contacting your server')
store.log.error('Failed to save feed', {e})
}
} }
}, [store, item]) }, [store, item])

View File

@ -29,6 +29,10 @@ export const SavedFeeds = observer(
} }
}, [store, isPageFocused]) }, [store, isPageFocused])
const onRefresh = useCallback(() => {
store.me.savedFeeds.refresh()
}, [store])
const renderListEmptyComponent = useCallback(() => { const renderListEmptyComponent = useCallback(() => {
return ( return (
<View <View
@ -73,7 +77,7 @@ export const SavedFeeds = observer(
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={store.me.savedFeeds.isRefreshing} refreshing={store.me.savedFeeds.isRefreshing}
onRefresh={() => store.me.savedFeeds.refresh()} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
progressViewOffset={headerOffset} progressViewOffset={headerOffset}

View File

@ -1,12 +1,9 @@
import React from 'react' import React from 'react'
import {Animated, View} from 'react-native' import {View} from 'react-native'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles' import {s} from 'lib/styles'
export interface RenderTabBarFnProps { export interface RenderTabBarFnProps {
selectedPage: number selectedPage: number
position: Animated.Value
offset: Animated.Value
onSelect?: (index: number) => void onSelect?: (index: number) => void
} }
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
@ -17,28 +14,29 @@ interface Props {
renderTabBar: RenderTabBarFn renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
} }
export const Pager = ({ export const Pager = React.forwardRef(
(
{
children, children,
tabBarPosition = 'top', tabBarPosition = 'top',
initialPage = 0, initialPage = 0,
renderTabBar, renderTabBar,
onPageSelected, onPageSelected,
}: React.PropsWithChildren<Props>) => { }: React.PropsWithChildren<Props>,
ref,
) => {
const [selectedPage, setSelectedPage] = React.useState(initialPage) const [selectedPage, setSelectedPage] = React.useState(initialPage)
const position = useAnimatedValue(0)
const offset = useAnimatedValue(0) React.useImperativeHandle(ref, () => ({
setPage: (index: number) => setSelectedPage(index),
}))
const onTabBarSelect = React.useCallback( const onTabBarSelect = React.useCallback(
(index: number) => { (index: number) => {
setSelectedPage(index) setSelectedPage(index)
onPageSelected?.(index) onPageSelected?.(index)
Animated.timing(position, {
toValue: index,
duration: 200,
useNativeDriver: true,
}).start()
}, },
[setSelectedPage, onPageSelected, position], [setSelectedPage, onPageSelected],
) )
return ( return (
@ -46,8 +44,6 @@ export const Pager = ({
{tabBarPosition === 'top' && {tabBarPosition === 'top' &&
renderTabBar({ renderTabBar({
selectedPage, selectedPage,
position,
offset,
onSelect: onTabBarSelect, onSelect: onTabBarSelect,
})} })}
{React.Children.map(children, (child, i) => ( {React.Children.map(children, (child, i) => (
@ -60,10 +56,9 @@ export const Pager = ({
{tabBarPosition === 'bottom' && {tabBarPosition === 'bottom' &&
renderTabBar({ renderTabBar({
selectedPage, selectedPage,
position,
offset,
onSelect: onTabBarSelect, onSelect: onTabBarSelect,
})} })}
</View> </View>
) )
} },
)

View File

@ -4,6 +4,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook' import useAppState from 'react-native-appstate-hook'
import isEqual from 'lodash.isequal'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {PostsFeedModel} from 'state/models/feeds/posts' import {PostsFeedModel} from 'state/models/feeds/posts'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
@ -44,15 +45,26 @@ export const HomeScreen = withAuthRequired(
}, [store]) }, [store])
React.useEffect(() => { React.useEffect(() => {
const {pinned} = store.me.savedFeeds
if (
isEqual(
pinned.map(p => p.uri),
customFeeds.map(f => (f.params as GetCustomFeed.QueryParams).feed),
)
) {
// no changes
return
}
const feeds = [] const feeds = []
for (const feed of store.me.savedFeeds.pinned) { for (const feed of pinned) {
const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) const model = new PostsFeedModel(store, 'custom', {feed: feed.uri})
model.setup() model.setup()
feeds.push(model) feeds.push(model)
} }
pagerRef.current?.setPage(0) pagerRef.current?.setPage(0)
setCustomFeeds(feeds) setCustomFeeds(feeds)
}, [store, store.me.savedFeeds.pinned, setCustomFeeds]) }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds])
React.useEffect(() => { React.useEffect(() => {
// refresh whats hot when lang preferences change // refresh whats hot when lang preferences change

View File

@ -27,26 +27,26 @@ import DraggableFlatList, {
import {CustomFeed} from 'view/com/feeds/CustomFeed' import {CustomFeed} from 'view/com/feeds/CustomFeed'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {CustomFeedModel} from 'state/models/feeds/custom-feed'
import * as Toast from 'view/com/util/Toast'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export const SavedFeeds = withAuthRequired( export const SavedFeeds = withAuthRequired(
observer(({}: Props) => { observer(({}: Props) => {
// hooks for global items
const pal = usePalette('default') const pal = usePalette('default')
const rootStore = useStores() const store = useStores()
const {screen} = useAnalytics() const {screen} = useAnalytics()
// hooks for local const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
screen('SavedFeeds') screen('SavedFeeds')
rootStore.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
savedFeeds.refresh() savedFeeds.refresh()
}, [screen, rootStore, savedFeeds]), }, [screen, store, savedFeeds]),
) )
const _ListEmptyComponent = () => {
const renderListEmptyComponent = useCallback(() => {
return ( return (
<View <View
style={[ style={[
@ -56,19 +56,33 @@ export const SavedFeeds = withAuthRequired(
styles.empty, styles.empty,
]}> ]}>
<Text type="lg" style={[pal.text]}> <Text type="lg" style={[pal.text]}>
You don't have any pinned feeds. To pin a feed, go back to the Saved You don't have any saved feeds.
Feeds screen and click the pin icon!
</Text> </Text>
</View> </View>
) )
} }, [pal])
const _ListFooterComponent = () => {
const renderListFooterComponent = useCallback(() => {
return ( return (
<View style={styles.footer}> <View style={styles.footer}>
{savedFeeds.isLoading && <ActivityIndicator />} {savedFeeds.isLoading && <ActivityIndicator />}
</View> </View>
) )
}, [savedFeeds])
const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
const onDragEnd = useCallback(
async ({data}) => {
try {
await savedFeeds.reorderPinnedFeeds(data)
} catch (e) {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to save pinned feed order', {e})
} }
},
[savedFeeds, store],
)
return ( return (
<CenteredView <CenteredView
@ -90,17 +104,17 @@ export const SavedFeeds = withAuthRequired(
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={savedFeeds.isRefreshing} refreshing={savedFeeds.isRefreshing}
onRefresh={() => savedFeeds.refresh()} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={pal.colors.text}
titleColor={pal.colors.text} titleColor={pal.colors.text}
/> />
} }
renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
initialNumToRender={10} initialNumToRender={10}
ListFooterComponent={_ListFooterComponent} ListFooterComponent={renderListFooterComponent}
ListEmptyComponent={_ListEmptyComponent} ListEmptyComponent={renderListEmptyComponent}
extraData={savedFeeds.isLoading} extraData={savedFeeds.isLoading}
onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)} onDragEnd={onDragEnd}
/> />
</CenteredView> </CenteredView>
) )
@ -110,13 +124,35 @@ export const SavedFeeds = withAuthRequired(
const ListItem = observer( const ListItem = observer(
({item, drag}: {item: CustomFeedModel; drag: () => void}) => { ({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
const pal = usePalette('default') const pal = usePalette('default')
const rootStore = useStores() const store = useStores()
const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore]) const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
const isPinned = savedFeeds.isPinned(item) const isPinned = savedFeeds.isPinned(item)
const onTogglePinned = useCallback( const onTogglePinned = useCallback(
() => savedFeeds.togglePinnedFeed(item), () =>
[savedFeeds, item], savedFeeds.togglePinnedFeed(item).catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to toggle pinned feed', {e})
}),
[savedFeeds, item, store],
) )
const onPressUp = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'up').catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to set pinned feed order', {e})
}),
[store, savedFeeds, item],
)
const onPressDown = useCallback(
() =>
savedFeeds.movePinnedFeed(item, 'down').catch(e => {
Toast.show('There was an issue contacting the server')
store.log.error('Failed to set pinned feed order', {e})
}),
[store, savedFeeds, item],
)
return ( return (
<ScaleDecorator> <ScaleDecorator>
<ShadowDecorator> <ShadowDecorator>
@ -128,9 +164,7 @@ const ListItem = observer(
<View style={styles.webArrowButtonsContainer}> <View style={styles.webArrowButtonsContainer}>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
onPress={() => { onPress={onPressUp}>
savedFeeds.movePinnedItem(item, 'up')
}}>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-up" icon="arrow-up"
size={12} size={12}
@ -139,9 +173,7 @@ const ListItem = observer(
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
onPress={() => { onPress={onPressDown}>
savedFeeds.movePinnedItem(item, 'down')
}}>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-down" icon="arrow-down"
size={12} size={12}