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

Configure Feed

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

at rm-opacity 255 lines 6.7 kB view raw
1import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' 2import shuffle from 'lodash.shuffle' 3import {RootStoreModel} from 'state/index' 4import {timeout} from 'lib/async/timeout' 5import {bundleAsync} from 'lib/async/bundle' 6import {feedUriToHref} from 'lib/strings/url-helpers' 7import {FeedTuner} from '../feed-manip' 8import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' 9 10const REQUEST_WAIT_MS = 500 // 500ms 11const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours 12 13export class MergeFeedAPI implements FeedAPI { 14 following: MergeFeedSource_Following 15 customFeeds: MergeFeedSource_Custom[] = [] 16 feedCursor = 0 17 itemCursor = 0 18 sampleCursor = 0 19 20 constructor(public rootStore: RootStoreModel) { 21 this.following = new MergeFeedSource_Following(this.rootStore) 22 } 23 24 reset() { 25 this.following = new MergeFeedSource_Following(this.rootStore) 26 this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() 27 this.feedCursor = 0 28 this.itemCursor = 0 29 this.sampleCursor = 0 30 } 31 32 async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { 33 const res = await this.rootStore.agent.getTimeline({ 34 limit: 1, 35 }) 36 return res.data.feed[0] 37 } 38 39 async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { 40 // we capture here to ensure the data has loaded 41 this._captureFeedsIfNeeded() 42 43 const promises = [] 44 45 // always keep following topped up 46 if (this.following.numReady < limit) { 47 promises.push(this.following.fetchNext(60)) 48 } 49 50 // pick the next feeds to sample from 51 const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3) 52 this.feedCursor += 3 53 if (this.feedCursor > this.customFeeds.length) { 54 this.feedCursor = 0 55 } 56 57 // top up the feeds 58 for (const feed of feeds) { 59 if (feed.numReady < 5) { 60 promises.push(feed.fetchNext(10)) 61 } 62 } 63 64 // wait for requests (all capped at a fixed timeout) 65 await Promise.all(promises) 66 67 // assemble a response by sampling from feeds with content 68 const posts: AppBskyFeedDefs.FeedViewPost[] = [] 69 while (posts.length < limit) { 70 let slice = this.sampleItem() 71 if (slice[0]) { 72 posts.push(slice[0]) 73 } else { 74 break 75 } 76 } 77 78 return { 79 cursor: posts.length ? 'fake' : undefined, 80 feed: posts, 81 } 82 } 83 84 sampleItem() { 85 const i = this.itemCursor++ 86 const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) 87 const canSample = candidateFeeds.length > 0 88 const hasFollows = this.following.hasMore 89 const hasFollowsReady = this.following.numReady > 0 90 91 // this condition establishes the frequency that custom feeds are woven into follows 92 const shouldSample = 93 i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0) 94 95 if (!canSample && !hasFollows) { 96 // no data available 97 return [] 98 } 99 if (shouldSample || !hasFollows) { 100 // time to sample, or the user isnt following anybody 101 return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) 102 } 103 if (!hasFollowsReady) { 104 // stop here so more follows can be fetched 105 return [] 106 } 107 // provide follow 108 return this.following.take(1) 109 } 110 111 _captureFeedsIfNeeded() { 112 if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) { 113 return 114 } 115 if (this.customFeeds.length === 0) { 116 this.customFeeds = shuffle( 117 this.rootStore.me.savedFeeds.all.map( 118 feed => 119 new MergeFeedSource_Custom( 120 this.rootStore, 121 feed.uri, 122 feed.displayName, 123 ), 124 ), 125 ) 126 } 127 } 128} 129 130class MergeFeedSource { 131 sourceInfo: FeedSourceInfo | undefined 132 cursor: string | undefined = undefined 133 queue: AppBskyFeedDefs.FeedViewPost[] = [] 134 hasMore = true 135 136 constructor(public rootStore: RootStoreModel) {} 137 138 get numReady() { 139 return this.queue.length 140 } 141 142 get needsFetch() { 143 return this.hasMore && this.queue.length === 0 144 } 145 146 reset() { 147 this.cursor = undefined 148 this.queue = [] 149 this.hasMore = true 150 } 151 152 take(n: number): AppBskyFeedDefs.FeedViewPost[] { 153 return this.queue.splice(0, n) 154 } 155 156 async fetchNext(n: number) { 157 await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)]) 158 } 159 160 _fetchNextInner = bundleAsync(async (n: number) => { 161 const res = await this._getFeed(this.cursor, n) 162 if (res.success) { 163 this.cursor = res.data.cursor 164 if (res.data.feed.length) { 165 this.queue = this.queue.concat(res.data.feed) 166 } else { 167 this.hasMore = false 168 } 169 } else { 170 this.hasMore = false 171 } 172 }) 173 174 protected _getFeed( 175 _cursor: string | undefined, 176 _limit: number, 177 ): Promise<AppBskyFeedGetTimeline.Response> { 178 throw new Error('Must be overridden') 179 } 180} 181 182class MergeFeedSource_Following extends MergeFeedSource { 183 tuner = new FeedTuner() 184 185 reset() { 186 super.reset() 187 this.tuner.reset() 188 } 189 190 async fetchNext(n: number) { 191 return this._fetchNextInner(n) 192 } 193 194 protected async _getFeed( 195 cursor: string | undefined, 196 limit: number, 197 ): Promise<AppBskyFeedGetTimeline.Response> { 198 const res = await this.rootStore.agent.getTimeline({cursor, limit}) 199 // run the tuner pre-emptively to ensure better mixing 200 const slices = this.tuner.tune( 201 res.data.feed, 202 this.rootStore.preferences.getFeedTuners('home'), 203 { 204 dryRun: false, 205 maintainOrder: true, 206 }, 207 ) 208 res.data.feed = slices.map(slice => slice.rootItem) 209 return res 210 } 211} 212 213class MergeFeedSource_Custom extends MergeFeedSource { 214 minDate: Date 215 216 constructor( 217 public rootStore: RootStoreModel, 218 public feedUri: string, 219 public feedDisplayName: string, 220 ) { 221 super(rootStore) 222 this.sourceInfo = { 223 displayName: feedDisplayName, 224 uri: feedUriToHref(feedUri), 225 } 226 this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) 227 } 228 229 protected async _getFeed( 230 cursor: string | undefined, 231 limit: number, 232 ): Promise<AppBskyFeedGetTimeline.Response> { 233 const res = await this.rootStore.agent.app.bsky.feed.getFeed({ 234 cursor, 235 limit, 236 feed: this.feedUri, 237 }) 238 // NOTE 239 // some custom feeds fail to enforce the pagination limit 240 // so we manually truncate here 241 // -prf 242 if (limit && res.data.feed.length > limit) { 243 res.data.feed = res.data.feed.slice(0, limit) 244 } 245 // filter out older posts 246 res.data.feed = res.data.feed.filter( 247 post => new Date(post.post.indexedAt) > this.minDate, 248 ) 249 // attach source info 250 for (const post of res.data.feed) { 251 post.__source = this.sourceInfo 252 } 253 return res 254 } 255}