Store/sync pinned feeds on the server
parent
d88c27a419
commit
7691fe4f48
|
@ -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()
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in New Issue