bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/stormlightlabs/skypanel/cli/internal/registry"
8 "github.com/stormlightlabs/skypanel/cli/internal/setup"
9 "github.com/stormlightlabs/skypanel/cli/internal/store"
10 "github.com/urfave/cli/v3"
11)
12
13// ListFollowingAction fetches and displays accounts the user follows
14func ListFollowingAction(ctx context.Context, cmd *cli.Command) error {
15 if err := setup.EnsurePersistenceReady(ctx); err != nil {
16 return fmt.Errorf("persistence layer not ready: %w", err)
17 }
18
19 reg := registry.Get()
20
21 service, err := reg.GetService()
22 if err != nil {
23 return fmt.Errorf("failed to get service: %w", err)
24 }
25
26 if !service.Authenticated() {
27 return fmt.Errorf("not authenticated: run 'skycli login' first")
28 }
29
30 cacheRepo, err := reg.GetCacheRepo()
31 if err != nil {
32 return fmt.Errorf("failed to get cache repository: %w", err)
33 }
34
35 actor := cmd.String("user")
36 if actor == "" {
37 actor = service.GetDid()
38 }
39 inactiveDays := cmd.Int("inactive")
40 mutual := cmd.Bool("mutual")
41 quietPosters := cmd.Bool("quiet")
42 quietThreshold := cmd.Float("threshold")
43 outputFormat := cmd.String("output")
44 refresh := cmd.Bool("refresh")
45
46 logger.Debugf("Fetching following for actor %v", actor)
47
48 var allFollowing []store.ActorProfile
49 cursor := ""
50 page := 0
51 for {
52 page++
53 response, err := service.GetFollows(ctx, actor, 100, cursor)
54 if err != nil {
55 return fmt.Errorf("failed to fetch following: %w", err)
56 }
57
58 allFollowing = append(allFollowing, response.Follows...)
59
60 if response.Cursor != "" {
61 logger.Infof("Fetched page %d (%d following so far)...", page, len(allFollowing))
62 }
63
64 if response.Cursor == "" {
65 break
66 }
67 cursor = response.Cursor
68 }
69
70 logger.Infof("Fetched %d total following", len(allFollowing))
71
72 if mutual {
73 var mutualFollows []store.ActorProfile
74 for _, follow := range allFollowing {
75 if follow.Viewer != nil && follow.Viewer.FollowedBy != "" {
76 mutualFollows = append(mutualFollows, follow)
77 }
78 }
79 allFollowing = mutualFollows
80 }
81
82 followerInfos, actors := enrichFollowerProfiles(ctx, service, allFollowing, logger)
83
84 if inactiveDays > 0 {
85 followerInfos = filterInactive(ctx, service, cacheRepo, followerInfos, actors, inactiveDays, refresh, logger)
86 }
87
88 if quietPosters {
89 followerInfos = filterQuiet(ctx, service, cacheRepo, followerInfos, actors, quietThreshold, refresh, logger)
90 }
91
92 switch outputFormat {
93 case "json":
94 return outputFollowersJSON(followerInfos)
95 case "csv":
96 return outputFollowersCSV(followerInfos, inactiveDays > 0 || quietPosters)
97 default:
98 displayFollowersTable(followerInfos, inactiveDays > 0 || quietPosters)
99 }
100
101 return nil
102}
103
104// FollowingCommand returns the following command
105func FollowingCommand() *cli.Command {
106 return &cli.Command{
107 Name: "following",
108 Usage: "Manage and analyze accounts you follow",
109 Commands: []*cli.Command{
110 {
111 Name: "list",
112 Usage: "List accounts you follow",
113 UsageText: "Fetch all accounts you follow with optional filters for inactive accounts and mutual follows.",
114 ArgsUsage: " ",
115 Flags: []cli.Flag{
116 &cli.StringFlag{
117 Name: "user",
118 Aliases: []string{"u"},
119 Usage: "User handle or DID (defaults to authenticated user)",
120 },
121 &cli.IntFlag{
122 Name: "inactive",
123 Usage: "Show only accounts with no posts in N days",
124 Value: 0,
125 },
126 &cli.BoolFlag{
127 Name: "mutual",
128 Usage: "Show only mutual follows",
129 },
130 &cli.BoolFlag{
131 Name: "quiet",
132 Usage: "Show only quiet posters (low posting frequency)",
133 },
134 &cli.FloatFlag{
135 Name: "threshold",
136 Usage: "Posts per day threshold for quiet posters (used with --quiet)",
137 Value: 1.0,
138 },
139 &cli.StringFlag{
140 Name: "output",
141 Aliases: []string{"o"},
142 Usage: "Output format: table, json, csv",
143 Value: "table",
144 },
145 &cli.BoolFlag{
146 Name: "refresh",
147 Usage: "Force refresh cached data (bypasses 24-hour cache)",
148 },
149 },
150 Action: ListFollowingAction,
151 },
152 },
153 }
154}