fork
Configure Feed
Select the types of activity you want to include in your feed.
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.
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}