diff --git a/docs/guide/features.md b/docs/guide/features.md
index 52bf664..12ab531 100644
--- a/docs/guide/features.md
+++ b/docs/guide/features.md
@@ -36,7 +36,7 @@ Trotsky provides comprehensive support for the Bluesky AT Protocol. Below is a l
**StepStarterPack** | :white_check_mark: | Get a starter pack by its URI. | ```Trotsky.init(agent).starterPack("at://...").run()```
**StepStarterPacks** | :white_check_mark: | Get a list of starter packs by their URIs. | ```Trotsky.init(agent).starterPacks([uri1, uri2]).each()```
**StepStreamPosts** | :test_tube: | Stream posts from the firehose. | ```Trotsky.init(agent).streamPosts().each()```
- **StepTimeline** | :x: | Get the authenticated user's timeline. | ```Trotsky.init(agent).timeline().take(20).each()```
+ **StepTimeline** | :white_check_mark: | Get the authenticated user's timeline. | ```Trotsky.init(agent).timeline().take(20).each()```
## Planned Features
diff --git a/lib/core/StepTimeline.ts b/lib/core/StepTimeline.ts
new file mode 100644
index 0000000..9c70f81
--- /dev/null
+++ b/lib/core/StepTimeline.ts
@@ -0,0 +1,116 @@
+import type { AppBskyFeedGetTimeline, AppBskyFeedDefs, AtpAgent } from "@atproto/api"
+
+import { StepPosts, type StepPostsOutput } from "./StepPosts"
+
+/**
+ * Type representing the output of the timeline retrieved by {@link StepTimeline}.
+ * @public
+ */
+export type StepTimelineOutput = StepPostsOutput
+
+/**
+ * Type representing the query parameters for retrieving the timeline.
+ * @public
+ */
+export type StepTimelineQueryParams = AppBskyFeedGetTimeline.QueryParams
+
+/**
+ * Type representing the cursor for paginated queries.
+ * @public
+ */
+export type StepTimelineQueryParamsCursor = StepTimelineQueryParams["cursor"] | undefined
+
+/**
+ * Represents a step for retrieving the authenticated user's timeline using the Bluesky API.
+ * Supports paginated retrieval of posts from followed accounts.
+ *
+ * @typeParam P - Type of the parent step.
+ * @typeParam C - Type of the context object, defaulting to `null`.
+ * @typeParam O - Type of the output object, extending {@link StepTimelineOutput}.
+ *
+ * @example
+ * Get recent posts from your timeline:
+ * ```ts
+ * await Trotsky.init(agent)
+ * .timeline()
+ * .take(20)
+ * .each()
+ * .tap((step) => {
+ * console.log(`@${step.context.author.handle}: ${step.context.record.text}`)
+ * })
+ * .run()
+ * ```
+ *
+ * @example
+ * Like posts from your timeline with specific criteria:
+ * ```ts
+ * await Trotsky.init(agent)
+ * .timeline()
+ * .take(50)
+ * .each()
+ * .when((step) => step?.context?.record?.text?.includes("#typescript"))
+ * .like()
+ * .wait(1000)
+ * .run()
+ * ```
+ *
+ * @example
+ * Use a custom algorithm for timeline:
+ * ```ts
+ * await Trotsky.init(agent)
+ * .timeline({ algorithm: "reverse-chronological" })
+ * .take(10)
+ * .each()
+ * .run()
+ * ```
+ *
+ * @public
+ */
+export class StepTimeline
extends StepPosts
{
+
+ /**
+ * Query parameters for the timeline request.
+ */
+ _queryParams: StepTimelineQueryParams
+
+ /**
+ * Initializes the StepTimeline instance with the given agent, parent, and optional query parameters.
+ *
+ * @param agent - The AT protocol agent used for API calls.
+ * @param parent - The parent step in the chain.
+ * @param queryParams - Optional query parameters for the timeline (e.g., algorithm).
+ */
+ constructor (agent: AtpAgent, parent: P, queryParams: StepTimelineQueryParams = {}) {
+ super(agent, parent)
+ this._queryParams = queryParams
+ }
+
+ /**
+ * Clones the current step and returns a new instance with the same parameters.
+ * @returns A new {@link StepTimeline} instance.
+ */
+ override clone () {
+ return super.clone(this._queryParams)
+ }
+
+ /**
+ * Applies pagination to retrieve timeline posts and sets the output.
+ * Fetches paginated results using the agent and appends them to the output.
+ */
+ async applyPagination () {
+ const feed = await this.paginate("feed", (cursor) => {
+ return this.agent.app.bsky.feed.getTimeline(this.queryParams(cursor))
+ })
+
+ this.output = feed.map((post: AppBskyFeedDefs.FeedViewPost) => post.post) as O
+ }
+
+ /**
+ * Generates query parameters for retrieving the timeline, including the optional cursor.
+ * @param cursor - The cursor for paginated queries.
+ * @returns The query parameters for retrieving the timeline.
+ */
+ queryParams (cursor: StepTimelineQueryParamsCursor): StepTimelineQueryParams {
+ return { ...this._queryParams, cursor }
+ }
+}
diff --git a/lib/core/Trotsky.ts b/lib/core/Trotsky.ts
index c38dec6..74c99fa 100644
--- a/lib/core/Trotsky.ts
+++ b/lib/core/Trotsky.ts
@@ -7,6 +7,7 @@ import type { StepPostsUris } from "./StepPosts"
import type { StepCreatePostParams } from "./StepCreatePost"
import type { StepWhenPredicate } from "./StepWhen"
import type { StepTapInterceptor } from "./StepTap"
+import type { StepTimelineQueryParams } from "./StepTimeline"
import type { Resolvable } from "./utils/resolvable"
import type { StepStarterPackUri } from "./StepStarterPack"
import type { StepStarterPacksUris } from "./StepStarterPacks"
@@ -31,7 +32,8 @@ import {
StepActorsParam,
StepPosts,
StepSave,
- StepSavePath
+ StepSavePath,
+ StepTimeline
} from "../trotsky"
/**
@@ -141,6 +143,15 @@ export class Trotsky extends StepBuilder {
return this.append(StepStreamPosts) as T
}
+ /**
+ * Adds a {@link StepTimeline} step.
+ * @param queryParams - Optional query parameters for the timeline.
+ * @returns The new {@link StepTimeline} instance.
+ */
+ timeline (queryParams: StepTimelineQueryParams = {}): StepTimeline {
+ return this.append(StepTimeline, queryParams)
+ }
+
/**
* Adds a {@link StepSave} step.
* @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.
diff --git a/lib/trotsky.ts b/lib/trotsky.ts
index d6367d2..f25fbb4 100644
--- a/lib/trotsky.ts
+++ b/lib/trotsky.ts
@@ -65,6 +65,7 @@ export * from "./core/StepActorsEntry"
export * from "./core/StepPosts"
export * from "./core/StepPostsEntry"
export * from "./core/StepSearchPosts"
+export * from "./core/StepTimeline"
// List of lists
export * from "./core/StepLists"
diff --git a/tests/core/StepTimeline.test.ts b/tests/core/StepTimeline.test.ts
new file mode 100644
index 0000000..948fa18
--- /dev/null
+++ b/tests/core/StepTimeline.test.ts
@@ -0,0 +1,218 @@
+import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"
+import { TestNetwork, SeedClient, usersSeed } from "@atproto/dev-env"
+import { AtpAgent } from "@atproto/api"
+import { Trotsky, StepTimeline } from "../../lib/trotsky"
+
+describe("StepTimeline", () => {
+ let network: TestNetwork
+ let agent: AtpAgent
+ let sc: SeedClient
+
+ // accounts
+ let bob: { "did": string; "handle": string; "password": string }
+ let alice: { "did": string; "handle": string; "password": string }
+ let carol: { "did": string; "handle": string; "password": string }
+
+ beforeAll(async () => {
+ network = await TestNetwork.create({
+ "dbPostgresSchema": "trotsky_step_timeline"
+ })
+
+ agent = network.pds.getClient()
+ sc = network.getSeedClient()
+
+ // Seed users
+ await usersSeed(sc)
+ bob = sc.accounts[sc.dids.bob]
+ alice = sc.accounts[sc.dids.alice]
+ carol = sc.accounts[sc.dids.carol]
+
+ // Login as bob
+ await agent.login({ "identifier": bob.handle, "password": bob.password })
+
+ // Bob follows Alice and Carol
+ await agent.app.bsky.graph.follow.create(
+ { "repo": bob.did },
+ { "subject": alice.did, "createdAt": new Date().toISOString() }
+ )
+ await agent.app.bsky.graph.follow.create(
+ { "repo": bob.did },
+ { "subject": carol.did, "createdAt": new Date().toISOString() }
+ )
+
+ // Alice and Carol create posts
+ await sc.post(alice.did, "Alice's first post about TypeScript")
+ await sc.post(alice.did, "Alice's second post about JavaScript")
+ await sc.post(carol.did, "Carol's post about Python")
+ await sc.post(carol.did, "Carol's post about Rust")
+
+ await network.processAll()
+ }, 120e3)
+
+ afterAll(async () => {
+ await network.close()
+ })
+
+ test("should clone properly", () => {
+ const step = Trotsky.init(agent).timeline()
+ const cloned = step.clone()
+ expect(cloned).toBeInstanceOf(StepTimeline)
+ })
+
+ test("should get timeline posts", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline()
+ .runHere()
+
+ expect(timeline).toBeInstanceOf(StepTimeline)
+ expect(timeline.output).toBeInstanceOf(Array)
+ // Bob should see posts from Alice and Carol (at least 4 posts)
+ expect(timeline.output.length).toBeGreaterThanOrEqual(4)
+ })
+
+ test("should return posts with correct structure", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline()
+ .runHere()
+
+ timeline.output.forEach(post => {
+ expect(post).toHaveProperty("uri")
+ expect(post).toHaveProperty("cid")
+ expect(post).toHaveProperty("author")
+ expect(post).toHaveProperty("record")
+ expect(post.author).toHaveProperty("handle")
+ expect(post.author).toHaveProperty("did")
+ })
+ })
+
+ test("should verify timeline contains posts from followed users", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline()
+ .runHere()
+
+ const authorDids = timeline.output.map(post => post.author.did)
+
+ // Timeline should contain posts from Alice or Carol
+ const hasAliceOrCarol = authorDids.some(did =>
+ did === alice.did || did === carol.did
+ )
+ expect(hasAliceOrCarol).toBe(true)
+ })
+
+ test("should iterate through each timeline post", async () => {
+ const postUris: string[] = []
+
+ await Trotsky.init(agent)
+ .timeline()
+ .take(3)
+ .each()
+ .tap((step) => {
+ if (step?.context?.uri) {
+ postUris.push(step.context.uri)
+ }
+ })
+ .run()
+
+ expect(postUris.length).toBeGreaterThan(0)
+ expect(postUris.length).toBeLessThanOrEqual(3)
+ })
+
+ test("should filter timeline posts with when()", async () => {
+ const filteredPosts: string[] = []
+
+ await Trotsky.init(agent)
+ .timeline()
+ .take(10)
+ .each()
+ .when((step) => step?.context?.author?.did === alice.did)
+ .tap((step) => {
+ if (step?.context?.uri) {
+ filteredPosts.push(step.context.uri)
+ }
+ })
+ .run()
+
+ // All filtered posts should be from Alice
+ const timeline = await Trotsky.init(agent).timeline().runHere()
+ const alicePosts = timeline.output.filter(p => p.author.did === alice.did)
+
+ expect(filteredPosts.length).toBeLessThanOrEqual(alicePosts.length)
+ })
+
+ test("should handle pagination with take()", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline()
+ .take(2)
+ .runHere()
+
+ expect(timeline.output.length).toBeLessThanOrEqual(2)
+ })
+
+ test("should work with custom query parameters", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline({ "limit": 5 })
+ .runHere()
+
+ expect(timeline.output).toBeInstanceOf(Array)
+ expect(timeline.output.length).toBeLessThanOrEqual(5)
+ })
+
+ test("should return indexed timestamps", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline()
+ .take(5)
+ .runHere()
+
+ timeline.output.forEach(post => {
+ expect(post).toHaveProperty("indexedAt")
+ expect(post.indexedAt).toBeTruthy()
+ })
+ })
+
+ test("should work with tap() to process results", async () => {
+ let processedCount = 0
+
+ await Trotsky.init(agent)
+ .timeline()
+ .take(3)
+ .each()
+ .tap(() => {
+ processedCount++
+ })
+ .run()
+
+ expect(processedCount).toBeGreaterThan(0)
+ expect(processedCount).toBeLessThanOrEqual(3)
+ })
+
+ test("should handle empty timeline for new user", async () => {
+ // Create a new user who doesn't follow anyone
+ await sc.createAccount("newuser", {
+ "handle": "newuser.test",
+ "email": "newuser@test.com",
+ "password": "password"
+ })
+
+ const newAgent = network.pds.getClient()
+ await newAgent.login({ "identifier": "newuser.test", "password": "password" })
+
+ await network.processAll()
+
+ const timeline = await Trotsky.init(newAgent)
+ .timeline()
+ .runHere()
+
+ expect(timeline.output).toBeInstanceOf(Array)
+ // New user should have empty or minimal timeline
+ expect(timeline.output.length).toBeLessThanOrEqual(10)
+ })
+
+ test("should support algorithm parameter", async () => {
+ const timeline = await Trotsky.init(agent)
+ .timeline({ "algorithm": "reverse-chronological" })
+ .take(5)
+ .runHere()
+
+ expect(timeline.output).toBeInstanceOf(Array)
+ })
+})