Skip to content

Commit fff8edb

Browse files
committed
feat: implement StepTimeline for retrieving authenticated user's timeline
1 parent 38ba8d0 commit fff8edb

File tree

5 files changed

+348
-2
lines changed

5 files changed

+348
-2
lines changed

docs/guide/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Trotsky provides comprehensive support for the Bluesky AT Protocol. Below is a l
3636
**StepStarterPack** | :white_check_mark: | Get a starter pack by its URI. | ```Trotsky.init(agent).starterPack("at://...").run()```
3737
**StepStarterPacks** | :white_check_mark: | Get a list of starter packs by their URIs. | ```Trotsky.init(agent).starterPacks([uri1, uri2]).each()```
3838
**StepStreamPosts** | :test_tube: | Stream posts from the firehose. | ```Trotsky.init(agent).streamPosts().each()```
39-
**StepTimeline** | :x: | Get the authenticated user's timeline. | ```Trotsky.init(agent).timeline().take(20).each()```
39+
**StepTimeline** | :white_check_mark: | Get the authenticated user's timeline. | ```Trotsky.init(agent).timeline().take(20).each()```
4040

4141
## Planned Features
4242

lib/core/StepTimeline.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { AppBskyFeedGetTimeline, AppBskyFeedDefs, AtpAgent } from "@atproto/api"
2+
3+
import { StepPosts, type StepPostsOutput } from "./StepPosts"
4+
5+
/**
6+
* Type representing the output of the timeline retrieved by {@link StepTimeline}.
7+
* @public
8+
*/
9+
export type StepTimelineOutput = StepPostsOutput
10+
11+
/**
12+
* Type representing the query parameters for retrieving the timeline.
13+
* @public
14+
*/
15+
export type StepTimelineQueryParams = AppBskyFeedGetTimeline.QueryParams
16+
17+
/**
18+
* Type representing the cursor for paginated queries.
19+
* @public
20+
*/
21+
export type StepTimelineQueryParamsCursor = StepTimelineQueryParams["cursor"] | undefined
22+
23+
/**
24+
* Represents a step for retrieving the authenticated user's timeline using the Bluesky API.
25+
* Supports paginated retrieval of posts from followed accounts.
26+
*
27+
* @typeParam P - Type of the parent step.
28+
* @typeParam C - Type of the context object, defaulting to `null`.
29+
* @typeParam O - Type of the output object, extending {@link StepTimelineOutput}.
30+
*
31+
* @example
32+
* Get recent posts from your timeline:
33+
* ```ts
34+
* await Trotsky.init(agent)
35+
* .timeline()
36+
* .take(20)
37+
* .each()
38+
* .tap((step) => {
39+
* console.log(`@${step.context.author.handle}: ${step.context.record.text}`)
40+
* })
41+
* .run()
42+
* ```
43+
*
44+
* @example
45+
* Like posts from your timeline with specific criteria:
46+
* ```ts
47+
* await Trotsky.init(agent)
48+
* .timeline()
49+
* .take(50)
50+
* .each()
51+
* .when((step) => step?.context?.record?.text?.includes("#typescript"))
52+
* .like()
53+
* .wait(1000)
54+
* .run()
55+
* ```
56+
*
57+
* @example
58+
* Use a custom algorithm for timeline:
59+
* ```ts
60+
* await Trotsky.init(agent)
61+
* .timeline({ algorithm: "reverse-chronological" })
62+
* .take(10)
63+
* .each()
64+
* .run()
65+
* ```
66+
*
67+
* @public
68+
*/
69+
export class StepTimeline<P, C = null, O extends StepTimelineOutput = StepTimelineOutput> extends StepPosts<P, C, O> {
70+
71+
/**
72+
* Query parameters for the timeline request.
73+
*/
74+
_queryParams: StepTimelineQueryParams
75+
76+
/**
77+
* Initializes the StepTimeline instance with the given agent, parent, and optional query parameters.
78+
*
79+
* @param agent - The AT protocol agent used for API calls.
80+
* @param parent - The parent step in the chain.
81+
* @param queryParams - Optional query parameters for the timeline (e.g., algorithm).
82+
*/
83+
constructor (agent: AtpAgent, parent: P, queryParams: StepTimelineQueryParams = {}) {
84+
super(agent, parent)
85+
this._queryParams = queryParams
86+
}
87+
88+
/**
89+
* Clones the current step and returns a new instance with the same parameters.
90+
* @returns A new {@link StepTimeline} instance.
91+
*/
92+
override clone () {
93+
return super.clone(this._queryParams)
94+
}
95+
96+
/**
97+
* Applies pagination to retrieve timeline posts and sets the output.
98+
* Fetches paginated results using the agent and appends them to the output.
99+
*/
100+
async applyPagination () {
101+
const feed = await this.paginate<AppBskyFeedDefs.FeedViewPost[], AppBskyFeedGetTimeline.Response>("feed", (cursor) => {
102+
return this.agent.app.bsky.feed.getTimeline(this.queryParams(cursor))
103+
})
104+
105+
this.output = feed.map((post: AppBskyFeedDefs.FeedViewPost) => post.post) as O
106+
}
107+
108+
/**
109+
* Generates query parameters for retrieving the timeline, including the optional cursor.
110+
* @param cursor - The cursor for paginated queries.
111+
* @returns The query parameters for retrieving the timeline.
112+
*/
113+
queryParams (cursor: StepTimelineQueryParamsCursor): StepTimelineQueryParams {
114+
return { ...this._queryParams, cursor }
115+
}
116+
}

lib/core/Trotsky.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { StepPostsUris } from "./StepPosts"
77
import type { StepCreatePostParams } from "./StepCreatePost"
88
import type { StepWhenPredicate } from "./StepWhen"
99
import type { StepTapInterceptor } from "./StepTap"
10+
import type { StepTimelineQueryParams } from "./StepTimeline"
1011
import type { Resolvable } from "./utils/resolvable"
1112
import type { StepStarterPackUri } from "./StepStarterPack"
1213
import type { StepStarterPacksUris } from "./StepStarterPacks"
@@ -31,7 +32,8 @@ import {
3132
StepActorsParam,
3233
StepPosts,
3334
StepSave,
34-
StepSavePath
35+
StepSavePath,
36+
StepTimeline
3537
} from "../trotsky"
3638

3739
/**
@@ -141,6 +143,15 @@ export class Trotsky extends StepBuilder {
141143
return this.append(StepStreamPosts<this>) as T
142144
}
143145

146+
/**
147+
* Adds a {@link StepTimeline} step.
148+
* @param queryParams - Optional query parameters for the timeline.
149+
* @returns The new {@link StepTimeline} instance.
150+
*/
151+
timeline (queryParams: StepTimelineQueryParams = {}): StepTimeline<this> {
152+
return this.append(StepTimeline<this>, queryParams)
153+
}
154+
144155
/**
145156
* Adds a {@link StepSave} step.
146157
* @param path - The path of the JSON file to save the output. If not provided, the file path will be created using the current step name.

lib/trotsky.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export * from "./core/StepActorsEntry"
6565
export * from "./core/StepPosts"
6666
export * from "./core/StepPostsEntry"
6767
export * from "./core/StepSearchPosts"
68+
export * from "./core/StepTimeline"
6869

6970
// List of lists
7071
export * from "./core/StepLists"

tests/core/StepTimeline.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"
2+
import { TestNetwork, SeedClient, usersSeed } from "@atproto/dev-env"
3+
import { AtpAgent } from "@atproto/api"
4+
import { Trotsky, StepTimeline } from "../../lib/trotsky"
5+
6+
describe("StepTimeline", () => {
7+
let network: TestNetwork
8+
let agent: AtpAgent
9+
let sc: SeedClient
10+
11+
// accounts
12+
let bob: { "did": string; "handle": string; "password": string }
13+
let alice: { "did": string; "handle": string; "password": string }
14+
let carol: { "did": string; "handle": string; "password": string }
15+
16+
beforeAll(async () => {
17+
network = await TestNetwork.create({
18+
"dbPostgresSchema": "trotsky_step_timeline"
19+
})
20+
21+
agent = network.pds.getClient()
22+
sc = network.getSeedClient()
23+
24+
// Seed users
25+
await usersSeed(sc)
26+
bob = sc.accounts[sc.dids.bob]
27+
alice = sc.accounts[sc.dids.alice]
28+
carol = sc.accounts[sc.dids.carol]
29+
30+
// Login as bob
31+
await agent.login({ "identifier": bob.handle, "password": bob.password })
32+
33+
// Bob follows Alice and Carol
34+
await agent.app.bsky.graph.follow.create(
35+
{ "repo": bob.did },
36+
{ "subject": alice.did, "createdAt": new Date().toISOString() }
37+
)
38+
await agent.app.bsky.graph.follow.create(
39+
{ "repo": bob.did },
40+
{ "subject": carol.did, "createdAt": new Date().toISOString() }
41+
)
42+
43+
// Alice and Carol create posts
44+
await sc.post(alice.did, "Alice's first post about TypeScript")
45+
await sc.post(alice.did, "Alice's second post about JavaScript")
46+
await sc.post(carol.did, "Carol's post about Python")
47+
await sc.post(carol.did, "Carol's post about Rust")
48+
49+
await network.processAll()
50+
}, 120e3)
51+
52+
afterAll(async () => {
53+
await network.close()
54+
})
55+
56+
test("should clone properly", () => {
57+
const step = Trotsky.init(agent).timeline()
58+
const cloned = step.clone()
59+
expect(cloned).toBeInstanceOf(StepTimeline)
60+
})
61+
62+
test("should get timeline posts", async () => {
63+
const timeline = await Trotsky.init(agent)
64+
.timeline()
65+
.runHere()
66+
67+
expect(timeline).toBeInstanceOf(StepTimeline)
68+
expect(timeline.output).toBeInstanceOf(Array)
69+
// Bob should see posts from Alice and Carol (at least 4 posts)
70+
expect(timeline.output.length).toBeGreaterThanOrEqual(4)
71+
})
72+
73+
test("should return posts with correct structure", async () => {
74+
const timeline = await Trotsky.init(agent)
75+
.timeline()
76+
.runHere()
77+
78+
timeline.output.forEach(post => {
79+
expect(post).toHaveProperty("uri")
80+
expect(post).toHaveProperty("cid")
81+
expect(post).toHaveProperty("author")
82+
expect(post).toHaveProperty("record")
83+
expect(post.author).toHaveProperty("handle")
84+
expect(post.author).toHaveProperty("did")
85+
})
86+
})
87+
88+
test("should verify timeline contains posts from followed users", async () => {
89+
const timeline = await Trotsky.init(agent)
90+
.timeline()
91+
.runHere()
92+
93+
const authorDids = timeline.output.map(post => post.author.did)
94+
95+
// Timeline should contain posts from Alice or Carol
96+
const hasAliceOrCarol = authorDids.some(did =>
97+
did === alice.did || did === carol.did
98+
)
99+
expect(hasAliceOrCarol).toBe(true)
100+
})
101+
102+
test("should iterate through each timeline post", async () => {
103+
const postUris: string[] = []
104+
105+
await Trotsky.init(agent)
106+
.timeline()
107+
.take(3)
108+
.each()
109+
.tap((step) => {
110+
if (step?.context?.uri) {
111+
postUris.push(step.context.uri)
112+
}
113+
})
114+
.run()
115+
116+
expect(postUris.length).toBeGreaterThan(0)
117+
expect(postUris.length).toBeLessThanOrEqual(3)
118+
})
119+
120+
test("should filter timeline posts with when()", async () => {
121+
const filteredPosts: string[] = []
122+
123+
await Trotsky.init(agent)
124+
.timeline()
125+
.take(10)
126+
.each()
127+
.when((step) => step?.context?.author?.did === alice.did)
128+
.tap((step) => {
129+
if (step?.context?.uri) {
130+
filteredPosts.push(step.context.uri)
131+
}
132+
})
133+
.run()
134+
135+
// All filtered posts should be from Alice
136+
const timeline = await Trotsky.init(agent).timeline().runHere()
137+
const alicePosts = timeline.output.filter(p => p.author.did === alice.did)
138+
139+
expect(filteredPosts.length).toBeLessThanOrEqual(alicePosts.length)
140+
})
141+
142+
test("should handle pagination with take()", async () => {
143+
const timeline = await Trotsky.init(agent)
144+
.timeline()
145+
.take(2)
146+
.runHere()
147+
148+
expect(timeline.output.length).toBeLessThanOrEqual(2)
149+
})
150+
151+
test("should work with custom query parameters", async () => {
152+
const timeline = await Trotsky.init(agent)
153+
.timeline({ "limit": 5 })
154+
.runHere()
155+
156+
expect(timeline.output).toBeInstanceOf(Array)
157+
expect(timeline.output.length).toBeLessThanOrEqual(5)
158+
})
159+
160+
test("should return indexed timestamps", async () => {
161+
const timeline = await Trotsky.init(agent)
162+
.timeline()
163+
.take(5)
164+
.runHere()
165+
166+
timeline.output.forEach(post => {
167+
expect(post).toHaveProperty("indexedAt")
168+
expect(post.indexedAt).toBeTruthy()
169+
})
170+
})
171+
172+
test("should work with tap() to process results", async () => {
173+
let processedCount = 0
174+
175+
await Trotsky.init(agent)
176+
.timeline()
177+
.take(3)
178+
.each()
179+
.tap(() => {
180+
processedCount++
181+
})
182+
.run()
183+
184+
expect(processedCount).toBeGreaterThan(0)
185+
expect(processedCount).toBeLessThanOrEqual(3)
186+
})
187+
188+
test("should handle empty timeline for new user", async () => {
189+
// Create a new user who doesn't follow anyone
190+
await sc.createAccount("newuser", {
191+
"handle": "newuser.test",
192+
"email": "newuser@test.com",
193+
"password": "password"
194+
})
195+
196+
const newAgent = network.pds.getClient()
197+
await newAgent.login({ "identifier": "newuser.test", "password": "password" })
198+
199+
await network.processAll()
200+
201+
const timeline = await Trotsky.init(newAgent)
202+
.timeline()
203+
.runHere()
204+
205+
expect(timeline.output).toBeInstanceOf(Array)
206+
// New user should have empty or minimal timeline
207+
expect(timeline.output.length).toBeLessThanOrEqual(10)
208+
})
209+
210+
test("should support algorithm parameter", async () => {
211+
const timeline = await Trotsky.init(agent)
212+
.timeline({ "algorithm": "reverse-chronological" })
213+
.take(5)
214+
.runHere()
215+
216+
expect(timeline.output).toBeInstanceOf(Array)
217+
})
218+
})

0 commit comments

Comments
 (0)