A locally focused bluesky appview
1package hydration
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "log/slog"
8 "strings"
9 "sync"
10
11 "github.com/bluesky-social/indigo/api/bsky"
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "github.com/whyrusleeping/market/models"
14)
15
16// ActorInfo contains hydrated actor information
17type ActorInfo struct {
18 DID string
19 Handle string
20 Profile *bsky.ActorProfile
21}
22
23// HydrateActor hydrates full actor information
24func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) {
25 ctx, span := tracer.Start(ctx, "hydrateActor")
26 defer span.End()
27
28 // Look up handle
29 resp, err := h.dir.LookupDID(ctx, syntax.DID(did))
30 if err != nil {
31 return nil, fmt.Errorf("failed to lookup DID: %w", err)
32 }
33
34 info := &ActorInfo{
35 DID: did,
36 Handle: resp.Handle.String(),
37 }
38
39 // Load profile from database
40 var dbProfile struct {
41 Repo uint
42 Raw []byte
43 }
44 err = h.db.Raw("SELECT repo, raw FROM profiles WHERE repo = (SELECT id FROM repos WHERE did = ?)", did).
45 Scan(&dbProfile).Error
46 if err != nil {
47 slog.Error("failed to fetch user profile", "error", err)
48 } else {
49 if len(dbProfile.Raw) > 0 {
50 var profile bsky.ActorProfile
51 if err := profile.UnmarshalCBOR(bytes.NewReader(dbProfile.Raw)); err == nil {
52 info.Profile = &profile
53 }
54 } else {
55 h.addMissingActor(did)
56 }
57 }
58
59 return info, nil
60}
61
62type ActorInfoDetailed struct {
63 ActorInfo
64 FollowCount int64
65 FollowerCount int64
66 PostCount int64
67 ViewerState *bsky.ActorDefs_ViewerState
68}
69
70func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) {
71 act, err := h.HydrateActor(ctx, did)
72 if err != nil {
73 return nil, err
74 }
75
76 actd := ActorInfoDetailed{
77 ActorInfo: *act,
78 }
79
80 var wg sync.WaitGroup
81 wg.Go(func() {
82 c, err := h.getFollowCountForUser(ctx, did)
83 if err != nil {
84 slog.Error("failed to get follow count", "did", did, "error", err)
85 }
86 actd.FollowCount = c
87 })
88 wg.Go(func() {
89 c, err := h.getFollowerCountForUser(ctx, did)
90 if err != nil {
91 slog.Error("failed to get follower count", "did", did, "error", err)
92 }
93 actd.FollowerCount = c
94 })
95 wg.Go(func() {
96 c, err := h.getPostCountForUser(ctx, did)
97 if err != nil {
98 slog.Error("failed to get post count", "did", did, "error", err)
99 }
100 actd.PostCount = c
101 })
102
103 if viewer != "" {
104 wg.Go(func() {
105 vs, err := h.getProfileViewerState(ctx, did, viewer)
106 if err != nil {
107 slog.Error("failed to get viewer state", "did", did, "viewer", viewer, "error", err)
108 }
109 actd.ViewerState = vs
110 })
111 }
112
113 wg.Wait()
114
115 return &actd, nil
116}
117
118func (h *Hydrator) getProfileViewerState(ctx context.Context, did, viewer string) (*bsky.ActorDefs_ViewerState, error) {
119 vs := &bsky.ActorDefs_ViewerState{}
120
121 var wg sync.WaitGroup
122
123 // Check if viewer is blocked by the target account
124 wg.Go(func() {
125 blockedBy, err := h.getBlockPair(ctx, did, viewer)
126 if err != nil {
127 slog.Error("failed to get blockedBy relationship", "did", did, "viewer", viewer, "error", err)
128 return
129 }
130
131 if blockedBy != nil {
132 v := true
133 vs.BlockedBy = &v
134 }
135 })
136
137 // Check if viewer is blocking the target account
138 wg.Go(func() {
139 blocking, err := h.getBlockPair(ctx, viewer, did)
140 if err != nil {
141 slog.Error("failed to get blocking relationship", "did", did, "viewer", viewer, "error", err)
142 return
143 }
144
145 if blocking != nil {
146 uri := fmt.Sprintf("at://%s/app.bsky.graph.block/%s", viewer, blocking.Rkey)
147 vs.Blocking = &uri
148 }
149 })
150
151 // Check if viewer is following the target account
152 wg.Go(func() {
153 following, err := h.getFollowPair(ctx, viewer, did)
154 if err != nil {
155 slog.Error("failed to get following relationship", "did", did, "viewer", viewer, "error", err)
156 return
157 }
158
159 if following != nil {
160 uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", viewer, following.Rkey)
161 vs.Following = &uri
162 }
163 })
164
165 // Check if target account is following the viewer
166 wg.Go(func() {
167 followedBy, err := h.getFollowPair(ctx, did, viewer)
168 if err != nil {
169 slog.Error("failed to get followedBy relationship", "did", did, "viewer", viewer, "error", err)
170 return
171 }
172
173 if followedBy != nil {
174 uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", did, followedBy.Rkey)
175 vs.FollowedBy = &uri
176 }
177 })
178
179 wg.Wait()
180
181 return vs, nil
182}
183
184func (h *Hydrator) getBlockPair(ctx context.Context, a, b string) (*models.Block, error) {
185 var blk models.Block
186 if err := h.db.Raw("SELECT * FROM blocks WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&blk).Error; err != nil {
187 return nil, err
188 }
189 if blk.ID == 0 {
190 return nil, nil
191 }
192
193 return &blk, nil
194}
195
196func (h *Hydrator) getFollowPair(ctx context.Context, a, b string) (*models.Follow, error) {
197 var fol models.Follow
198 if err := h.db.Raw("SELECT * FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&fol).Error; err != nil {
199 return nil, err
200 }
201 if fol.ID == 0 {
202 return nil, nil
203 }
204
205 return &fol, nil
206}
207
208func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) {
209 var count int64
210 if err := h.db.Raw("SELECT count(*) FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
211 return 0, err
212 }
213
214 return count, nil
215}
216
217func (h *Hydrator) getFollowerCountForUser(ctx context.Context, did string) (int64, error) {
218 var count int64
219 if err := h.db.Raw("SELECT count(*) FROM follows WHERE subject = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
220 return 0, err
221 }
222
223 return count, nil
224}
225
226func (h *Hydrator) getPostCountForUser(ctx context.Context, did string) (int64, error) {
227 var count int64
228 if err := h.db.Raw("SELECT count(*) FROM posts WHERE author = (SELECT id FROM repos WHERE did = ?)", did).Scan(&count).Error; err != nil {
229 return 0, err
230 }
231
232 return count, nil
233}
234
235// HydrateActors hydrates multiple actors
236func (h *Hydrator) HydrateActors(ctx context.Context, dids []string) (map[string]*ActorInfo, error) {
237 result := make(map[string]*ActorInfo, len(dids))
238 for _, did := range dids {
239 info, err := h.HydrateActor(ctx, did)
240 if err != nil {
241 // Skip actors that fail to hydrate rather than failing the whole batch
242 continue
243 }
244 result[did] = info
245 }
246 return result, nil
247}
248
249// ResolveDID resolves a handle or DID to a DID
250func (h *Hydrator) ResolveDID(ctx context.Context, actor string) (string, error) {
251 // If it's already a DID, return it
252 if strings.HasPrefix(actor, "did:") {
253 return actor, nil
254 }
255
256 // Otherwise, resolve the handle
257 resp, err := h.dir.LookupHandle(ctx, syntax.Handle(actor))
258 if err != nil {
259 return "", fmt.Errorf("failed to resolve handle: %w", err)
260 }
261
262 return resp.DID.String(), nil
263}