tangled
alpha
login
or
join now
quilling.dev
/
parakeet
forked from
parakeet.at/parakeet
1
fork
atom
Rust AppView - highly experimental!
1
fork
atom
overview
issues
pulls
pipelines
chore: refactor parakeet
quilling.dev
2 months ago
a9ea85ec
1592a5dd
+1270
-1129
34 changed files
expand all
collapse all
unified
split
parakeet
Cargo.toml
src
entities
converters
post.rs
profile.rs
core
feedgen.rs
list.rs
post.rs
starterpack.rs
lib.rs
xrpc
app_bsky
actor.rs
bookmark.rs
feed
feedgen.rs
get_timeline.rs
likes.rs
posts
feeds.rs
helpers.rs
queries.rs
threads.rs
search.rs
graph
lists.rs
mutes.rs
relations.rs
search.rs
starter_packs.rs
suggestions.rs
labeler.rs
mod.rs
notification.rs
unspecced
handlers.rs
thread_v2
models.rs
other_replies.rs
post_thread.rs
sorting.rs
thread_builder.rs
community_lexicon
bookmarks.rs
+4
-1
parakeet/Cargo.toml
···
19
19
eyre = "0.6.12"
20
20
figment = { version = "0.10.19", features = ["env", "toml"] }
21
21
itertools = "0.14.0"
22
22
+
jacquard = { workspace = true }
23
23
+
jacquard-api = { workspace = true }
24
24
+
jacquard-axum = { workspace = true }
25
25
+
jacquard-common = { workspace = true }
22
26
jsonwebtoken = { git = "https://gitlab.com/parakeet-social/jsonwebtoken", branch = "es256k" }
23
23
-
lexica = { path = "../lexica" }
24
27
moka = { version = "0.12", features = ["future"] }
25
28
multibase = "0.9.1"
26
29
parakeet-db = { path = "../parakeet-db" }
+29
-31
parakeet/src/entities/converters/post.rs
···
3
3
/// This replaces the complex hydration layer with simple, direct conversions
4
4
5
5
use crate::entities::core::{PostEntity, PostData};
6
6
-
use lexica::app_bsky::feed::PostView;
7
7
-
use lexica::app_bsky::actor::ProfileViewBasic;
8
8
-
use lexica::app_bsky::RecordStats;
6
6
+
use jacquard_api::app_bsky::feed::PostView;
7
7
+
use jacquard_api::app_bsky::actor::ProfileViewBasic;
9
8
10
9
impl PostEntity {
11
10
/// Convert PostData directly to PostView
12
11
///
13
12
/// This is much simpler than the old hydration system - we just map the fields directly
14
14
-
pub fn post_to_post_view(&self, post_data: &PostData, author_profile: ProfileViewBasic) -> PostView {
15
15
-
let uri = format!(
16
16
-
"at://{}/app.bsky.feed.post/{}",
17
17
-
post_data.author.did,
18
18
-
parakeet_db::tid_util::encode_tid(post_data.post.rkey)
19
19
-
);
20
20
-
21
21
-
let cid = parakeet_db::cid_util::digest_to_record_cid_string(&post_data.post.cid)
22
22
-
.unwrap_or_default();
13
13
+
pub fn post_to_post_view<'a>(&self, post_data: &'a PostData, author_profile: ProfileViewBasic<'a>, uri: &'a str, cid: &'a str) -> PostView<'static> {
14
14
+
use jacquard_common::types::string::{AtUri, Cid};
15
15
+
use jacquard_common::types::string::Datetime;
16
16
+
use jacquard_common::IntoStatic;
23
17
24
24
-
PostView {
25
25
-
uri: uri.clone(),
26
26
-
cid,
27
27
-
author: author_profile,
28
28
-
record: serde_json::Value::Object(serde_json::Map::new()), // Would need actual record data
29
29
-
embed: None, // Would need embed hydration
30
30
-
stats: RecordStats {
31
31
-
reply_count: post_data.post.reply_count as i64,
32
32
-
repost_count: post_data.post.repost_count as i64,
33
33
-
like_count: post_data.post.like_count as i64,
34
34
-
quote_count: post_data.post.quote_count as i64,
35
35
-
bookmark_count: 0, // Not tracked in our schema
36
36
-
},
37
37
-
indexed_at: parakeet_db::tid_util::tid_to_datetime(post_data.post.rkey),
38
38
-
labels: vec![],
39
39
-
viewer: None, // Would need viewer state
40
40
-
threadgate: None, // Would need threadgate data
41
41
-
}
18
18
+
PostView::new()
19
19
+
.uri(AtUri::from(uri))
20
20
+
.cid(Cid::from(cid))
21
21
+
.author(author_profile)
22
22
+
.record(serde_json::Value::Object(serde_json::Map::new()))
23
23
+
.reply_count(Some(post_data.post.reply_count as i64))
24
24
+
.repost_count(Some(post_data.post.repost_count as i64))
25
25
+
.like_count(Some(post_data.post.like_count as i64))
26
26
+
.quote_count(Some(post_data.post.quote_count as i64))
27
27
+
.bookmark_count(Some(0))
28
28
+
.indexed_at(Datetime::new(
29
29
+
¶keet_db::tid_util::tid_to_datetime(post_data.post.rkey)
30
30
+
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
31
31
+
).unwrap())
32
32
+
.build()
33
33
+
.into_static()
42
34
}
43
35
44
36
/// Get PostView by composite key (direct conversion, no hydration)
···
50
42
.get_profile_view_basic(post_data.author.id, viewer_did)
51
43
.await?;
52
44
53
53
-
Some(self.post_to_post_view(&post_data, author_profile))
45
45
+
// Build the post URI and CID
46
46
+
let uri = format!("at://{}/app.bsky.feed.post/{}",
47
47
+
author_profile.did.as_str(),
48
48
+
parakeet_db::tid_util::encode_tid(post_data.post.rkey));
49
49
+
let cid = parakeet_db::cid_util::digest_to_blob_cid_string(&post_data.post.cid).unwrap_or_default();
50
50
+
51
51
+
Some(self.post_to_post_view(&post_data, author_profile, &uri, &cid))
54
52
}
55
53
56
54
/// Get PostView by URI
+283
-149
parakeet/src/entities/converters/profile.rs
···
4
4
5
5
use crate::entities::core::{ProfileEntity, PostEntity, StarterpackEntity};
6
6
use parakeet_db::models::Actor;
7
7
-
use lexica::app_bsky::actor::{
7
7
+
use jacquard_api::app_bsky::actor::{
8
8
ProfileView, ProfileViewDetailed, ProfileViewBasic,
9
9
-
ProfileAssociated, ProfileAssociatedChat, ChatAllowIncoming,
10
10
-
ProfileAssociatedActivitySubscription, ProfileAllowSubscriptions,
11
11
-
ProfileViewerState
9
9
+
ProfileAssociated, ProfileAssociatedChat,
10
10
+
ProfileAssociatedActivitySubscription, ViewerState
12
11
};
12
12
+
use jacquard_common::types::string::{CowStr, Did, Uri, Handle};
13
13
+
use jacquard_common::types::aturi::AtUri;
14
14
+
use jacquard_common::types::datetime::Datetime;
15
15
+
use jacquard_common::IntoStatic;
13
16
use std::str::FromStr;
14
17
use std::sync::Arc;
15
18
16
19
/// Standalone function for converting Actor to ProfileView
17
17
-
pub fn actor_to_profile_view(actor: &Actor) -> ProfileView {
18
18
-
ProfileView {
19
19
-
did: actor.did.clone(),
20
20
-
handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()),
21
21
-
display_name: actor.profile_display_name.clone(),
22
22
-
description: actor.profile_description.clone(),
23
23
-
avatar: actor.profile_avatar_cid.as_ref()
24
24
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
25
25
-
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))),
26
26
-
indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()),
27
27
-
created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()),
20
20
+
pub fn actor_to_profile_view(actor: &Actor) -> ProfileView<'static> {
21
21
+
let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string());
28
22
29
29
-
// Basic required fields
30
30
-
associated: None,
31
31
-
labels: Vec::new(),
32
32
-
viewer: None,
33
33
-
pronouns: actor.profile_pronouns.clone(),
34
34
-
status: None,
35
35
-
verification: None,
23
23
+
// Keep avatar_url alive for the entire function scope
24
24
+
let avatar_url = actor.profile_avatar_cid.as_ref()
25
25
+
.and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid))
26
26
+
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str));
27
27
+
28
28
+
let mut builder = ProfileView::new()
29
29
+
.did(Did::new(&actor.did).unwrap())
30
30
+
.handle(Handle::new(&handle_string).unwrap());
31
31
+
32
32
+
// Optional fields
33
33
+
if let Some(ref display_name) = actor.profile_display_name {
34
34
+
builder = builder.display_name(Some(CowStr::from(display_name.clone())));
35
35
+
}
36
36
+
37
37
+
if let Some(ref description) = actor.profile_description {
38
38
+
builder = builder.description(Some(CowStr::from(description.clone())));
39
39
+
}
40
40
+
41
41
+
if let Some(ref url) = avatar_url {
42
42
+
builder = builder.avatar(Some(Uri::new(url).unwrap()));
36
43
}
44
44
+
45
45
+
if let Some(indexed_at) = actor.last_indexed {
46
46
+
builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset()));
47
47
+
}
48
48
+
49
49
+
if let Some(created_at) = actor.account_created_at {
50
50
+
builder = builder.created_at(Datetime::new(created_at.fixed_offset()));
51
51
+
}
52
52
+
53
53
+
if let Some(ref pronouns) = actor.profile_pronouns {
54
54
+
builder = builder.pronouns(Some(CowStr::from(pronouns.clone())));
55
55
+
}
56
56
+
57
57
+
builder.build().into_static()
37
58
}
38
59
39
60
impl ProfileEntity {
40
61
/// Build viewer state for an actor from the perspective of a viewer
41
41
-
async fn build_viewer_state(&self, actor: &Actor, viewer_did: &str) -> Option<ProfileViewerState> {
62
62
+
async fn build_viewer_state(&self, actor: &Actor, viewer_did: &str) -> Option<ViewerState<'static>> {
42
63
// Get viewer's actor_id
43
64
let viewer_actor_id = self.resolve_identifier(viewer_did).await.ok()?;
44
65
···
88
109
parakeet_db::tid_util::encode_tid(b.rkey)))
89
110
});
90
111
91
91
-
Some(ProfileViewerState {
92
92
-
muted,
112
112
+
Some(ViewerState {
113
113
+
muted: Some(muted),
114
114
+
blocked_by: Some(blocked_by),
115
115
+
blocking: blocking.as_ref().and_then(|s| AtUri::new(s).ok()),
116
116
+
followed_by: followed_by.as_ref().and_then(|s| AtUri::new(s).ok()),
117
117
+
following: following.as_ref().and_then(|s| AtUri::new(s).ok()),
93
118
muted_by_list: None,
94
94
-
blocked_by,
95
95
-
blocking,
96
119
blocking_by_list: None,
97
97
-
following,
98
98
-
followed_by,
99
120
known_followers: None,
100
100
-
})
121
121
+
activity_subscription: None,
122
122
+
..Default::default()
123
123
+
}.into_static())
101
124
}
102
125
/// Convert an Actor directly to ProfileViewDetailed
103
126
///
104
127
/// This is much simpler than the old hydration system - we just map the fields directly
105
105
-
pub fn actor_to_profile_view_detailed(&self, actor: &Actor) -> ProfileViewDetailed {
106
106
-
ProfileViewDetailed {
107
107
-
did: actor.did.clone(),
108
108
-
handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()),
109
109
-
display_name: actor.profile_display_name.clone(),
110
110
-
description: actor.profile_description.clone(),
111
111
-
avatar: actor.profile_avatar_cid.as_ref()
112
112
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
113
113
-
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))),
114
114
-
banner: actor.profile_banner_cid.as_ref()
115
115
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
116
116
-
.map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str))),
117
117
-
followers_count: actor.followers_count.unwrap_or(0) as i64,
118
118
-
follows_count: actor.following_count.unwrap_or(0) as i64,
119
119
-
posts_count: actor.posts_count.unwrap_or(0) as i64,
120
120
-
indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()),
121
121
-
created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()),
128
128
+
pub fn actor_to_profile_view_detailed(&self, actor: &Actor) -> ProfileViewDetailed<'static> {
129
129
+
let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string());
122
130
123
123
-
// Associated data
124
124
-
associated: Some(ProfileAssociated {
125
125
-
lists: None,
131
131
+
// Keep URLs alive for the entire function scope
132
132
+
let avatar_url = actor.profile_avatar_cid.as_ref()
133
133
+
.and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid))
134
134
+
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str));
135
135
+
136
136
+
let banner_url = actor.profile_banner_cid.as_ref()
137
137
+
.and_then(|banner_cid| parakeet_db::cid_util::digest_to_blob_cid_string(banner_cid))
138
138
+
.map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str));
139
139
+
140
140
+
let mut builder = ProfileViewDetailed::new()
141
141
+
.did(Did::new(&actor.did).unwrap())
142
142
+
.handle(Handle::new(&handle_string).unwrap());
143
143
+
144
144
+
// Optional fields
145
145
+
if let Some(ref display_name) = actor.profile_display_name {
146
146
+
builder = builder.display_name(Some(CowStr::from(display_name.clone())));
147
147
+
}
148
148
+
149
149
+
if let Some(ref description) = actor.profile_description {
150
150
+
builder = builder.description(Some(CowStr::from(description.clone())));
151
151
+
}
152
152
+
153
153
+
if let Some(ref url) = avatar_url {
154
154
+
builder = builder.avatar(Some(Uri::new(url).unwrap()));
155
155
+
}
156
156
+
157
157
+
if let Some(ref url) = banner_url {
158
158
+
builder = builder.banner(Some(Uri::new(url).unwrap()));
159
159
+
}
160
160
+
161
161
+
builder = builder
162
162
+
.followers_count(actor.followers_count.unwrap_or(0) as i64)
163
163
+
.follows_count(actor.following_count.unwrap_or(0) as i64)
164
164
+
.posts_count(actor.posts_count.unwrap_or(0) as i64);
165
165
+
166
166
+
if let Some(indexed_at) = actor.last_indexed {
167
167
+
builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset()));
168
168
+
}
169
169
+
170
170
+
if let Some(created_at) = actor.account_created_at {
171
171
+
builder = builder.created_at(Datetime::new(created_at.fixed_offset()));
172
172
+
}
173
173
+
174
174
+
// Build associated data if any relevant fields are present
175
175
+
if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() {
176
176
+
let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| {
177
177
+
ProfileAssociatedChat {
178
178
+
allow_incoming: chat_type.to_string().into(),
179
179
+
..Default::default()
180
180
+
}.into_static()
181
181
+
});
182
182
+
183
183
+
let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| {
184
184
+
ProfileAssociatedActivitySubscription {
185
185
+
allow_subscriptions: sub_type.to_string().into(),
186
186
+
extra_data: None,
187
187
+
}.into_static()
188
188
+
});
189
189
+
190
190
+
let associated = ProfileAssociated {
191
191
+
chat,
192
192
+
activity_subscription,
126
193
feedgens: None,
194
194
+
lists: None,
127
195
starter_packs: None,
196
196
+
extra_data: None,
128
197
labeler: None,
129
129
-
chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| {
130
130
-
ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| {
131
131
-
ProfileAssociatedChat { allow_incoming: allow }
132
132
-
})
133
133
-
}),
134
134
-
activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| {
135
135
-
ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| {
136
136
-
ProfileAssociatedActivitySubscription { allow_subscriptions: allow }
137
137
-
})
138
138
-
}),
139
139
-
}),
198
198
+
}.into_static();
140
199
141
141
-
// These fields require viewer state - set to None/empty for now
142
142
-
viewer: None,
143
143
-
labels: vec![],
200
200
+
builder = builder.associated(associated);
201
201
+
}
144
202
145
145
-
// Additional optional fields
146
146
-
pronouns: actor.profile_pronouns.clone(),
147
147
-
website: actor.profile_website.clone(),
148
148
-
pinned_post: None, // Would need pinned post data from actor.pinned_post
149
149
-
joined_via_starter_pack: None, // Would need starter pack data
150
150
-
verification: None, // Would need verification data
151
151
-
status: None, // Would need status data
203
203
+
// Additional optional fields
204
204
+
if let Some(ref pronouns) = actor.profile_pronouns {
205
205
+
builder = builder.pronouns(Some(CowStr::from(pronouns.clone())));
152
206
}
207
207
+
208
208
+
builder.build().into_static()
153
209
}
154
210
155
211
/// Convert an Actor to ProfileViewDetailed with enriched fields (pinned_post, joined_via_starter_pack)
···
160
216
actor: &Actor,
161
217
post_entity: Option<&PostEntity>,
162
218
starterpack_entity: Option<&StarterpackEntity>,
163
163
-
) -> ProfileViewDetailed {
164
164
-
let mut profile = self.actor_to_profile_view_detailed(actor);
219
219
+
) -> ProfileViewDetailed<'static> {
220
220
+
let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string());
221
221
+
222
222
+
// Keep URLs alive for the entire function scope
223
223
+
let avatar_url = actor.profile_avatar_cid.as_ref()
224
224
+
.and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid))
225
225
+
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str));
226
226
+
227
227
+
let banner_url = actor.profile_banner_cid.as_ref()
228
228
+
.and_then(|banner_cid| parakeet_db::cid_util::digest_to_blob_cid_string(banner_cid))
229
229
+
.map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str));
230
230
+
231
231
+
let mut builder = ProfileViewDetailed::new()
232
232
+
.did(Did::new(&actor.did).unwrap())
233
233
+
.handle(Handle::new(&handle_string).unwrap());
234
234
+
235
235
+
// Copy all the fields from the basic conversion
236
236
+
if let Some(ref display_name) = actor.profile_display_name {
237
237
+
builder = builder.display_name(Some(CowStr::from(display_name.clone())));
238
238
+
}
239
239
+
240
240
+
if let Some(ref description) = actor.profile_description {
241
241
+
builder = builder.description(Some(CowStr::from(description.clone())));
242
242
+
}
243
243
+
244
244
+
if let Some(ref url) = avatar_url {
245
245
+
builder = builder.avatar(Some(Uri::new(url).unwrap()));
246
246
+
}
247
247
+
248
248
+
if let Some(ref url) = banner_url {
249
249
+
builder = builder.banner(Some(Uri::new(url).unwrap()));
250
250
+
}
251
251
+
252
252
+
builder = builder
253
253
+
.followers_count(actor.followers_count.unwrap_or(0) as i64)
254
254
+
.follows_count(actor.following_count.unwrap_or(0) as i64)
255
255
+
.posts_count(actor.posts_count.unwrap_or(0) as i64);
256
256
+
257
257
+
if let Some(indexed_at) = actor.last_indexed {
258
258
+
builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset()));
259
259
+
}
260
260
+
261
261
+
if let Some(created_at) = actor.account_created_at {
262
262
+
builder = builder.created_at(Datetime::new(created_at.fixed_offset()));
263
263
+
}
264
264
+
265
265
+
// Build associated data if any relevant fields are present
266
266
+
if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() {
267
267
+
let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| {
268
268
+
ProfileAssociatedChat {
269
269
+
allow_incoming: chat_type.to_string().into(),
270
270
+
..Default::default()
271
271
+
}.into_static()
272
272
+
});
273
273
+
274
274
+
let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| {
275
275
+
ProfileAssociatedActivitySubscription {
276
276
+
allow_subscriptions: sub_type.to_string().into(),
277
277
+
extra_data: None,
278
278
+
}.into_static()
279
279
+
});
280
280
+
281
281
+
let associated = ProfileAssociated {
282
282
+
chat,
283
283
+
activity_subscription,
284
284
+
feedgens: None,
285
285
+
lists: None,
286
286
+
starter_packs: None,
287
287
+
extra_data: None,
288
288
+
labeler: None,
289
289
+
}.into_static();
290
290
+
291
291
+
builder = builder.associated(associated);
292
292
+
}
165
293
166
294
// Populate pinned_post if available
167
295
if let (Some(pinned_rkey), Some(post_entity)) = (actor.profile_pinned_post_rkey, post_entity) {
168
168
-
// Use PostEntity's new method to get the post as a StrongRef
169
296
if let Ok(strong_ref) = post_entity.get_post_strong_ref(actor.id, pinned_rkey).await {
170
170
-
profile.pinned_post = strong_ref;
297
297
+
builder = builder.pinned_post(strong_ref);
171
298
}
172
299
}
173
300
174
301
// Populate joined_via_starter_pack if available
175
302
if let (Some(sp_id), Some(starterpack_entity)) = (actor.profile_joined_sp_id, starterpack_entity) {
176
176
-
profile.joined_via_starter_pack = starterpack_entity.get_by_id(sp_id).await.ok().flatten();
303
303
+
if let Some(sp) = starterpack_entity.get_by_id(sp_id).await.ok().flatten() {
304
304
+
builder = builder.joined_via_starter_pack(sp);
305
305
+
}
306
306
+
}
307
307
+
308
308
+
if let Some(ref pronouns) = actor.profile_pronouns {
309
309
+
builder = builder.pronouns(Some(CowStr::from(pronouns.clone())));
177
310
}
178
311
179
179
-
profile
312
312
+
builder.build().into_static()
180
313
}
181
314
182
315
/// Convert an Actor to ProfileView (simpler variant)
183
183
-
pub fn actor_to_profile_view(&self, actor: &Actor) -> ProfileView {
184
184
-
ProfileView {
185
185
-
did: actor.did.clone(),
186
186
-
handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()),
187
187
-
display_name: actor.profile_display_name.clone(),
188
188
-
description: actor.profile_description.clone(),
189
189
-
avatar: actor.profile_avatar_cid.as_ref()
190
190
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
191
191
-
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))),
192
192
-
indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()),
193
193
-
created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()),
194
194
-
viewer: None,
195
195
-
labels: vec![],
196
196
-
pronouns: actor.profile_pronouns.clone(),
197
197
-
status: None,
198
198
-
verification: None,
199
199
-
associated: Some(ProfileAssociated {
200
200
-
lists: None,
201
201
-
feedgens: None,
202
202
-
starter_packs: None,
203
203
-
labeler: None,
204
204
-
chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| {
205
205
-
ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| {
206
206
-
ProfileAssociatedChat { allow_incoming: allow }
207
207
-
})
208
208
-
}),
209
209
-
activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| {
210
210
-
ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| {
211
211
-
ProfileAssociatedActivitySubscription { allow_subscriptions: allow }
212
212
-
})
213
213
-
}),
214
214
-
}),
215
215
-
}
316
316
+
pub fn actor_to_profile_view(&self, actor: &Actor) -> ProfileView<'static> {
317
317
+
// Use the standalone function we defined at the top
318
318
+
actor_to_profile_view(actor)
216
319
}
217
320
218
321
/// Convert an Actor to ProfileViewBasic (minimal variant)
219
219
-
pub async fn actor_to_profile_view_basic(&self, actor: &Actor, viewer_did: Option<&str>) -> ProfileViewBasic {
322
322
+
pub async fn actor_to_profile_view_basic(&self, actor: &Actor, viewer_did: Option<&str>) -> ProfileViewBasic<'static> {
220
323
let viewer = if let Some(did) = viewer_did {
221
324
if did != actor.did.as_str() {
222
325
self.build_viewer_state(actor, did).await
···
227
330
None
228
331
};
229
332
230
230
-
ProfileViewBasic {
231
231
-
did: actor.did.clone(),
232
232
-
handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()),
233
233
-
display_name: actor.profile_display_name.clone(),
234
234
-
avatar: actor.profile_avatar_cid.as_ref()
235
235
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
236
236
-
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))),
237
237
-
viewer,
238
238
-
labels: vec![],
239
239
-
created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()),
240
240
-
pronouns: actor.profile_pronouns.clone(),
241
241
-
status: None,
242
242
-
verification: None,
243
243
-
associated: Some(ProfileAssociated {
244
244
-
lists: None,
333
333
+
let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string());
334
334
+
335
335
+
// Keep avatar_url alive for the entire function scope
336
336
+
let avatar_url = actor.profile_avatar_cid.as_ref()
337
337
+
.and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid))
338
338
+
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str));
339
339
+
340
340
+
let mut builder = ProfileViewBasic::new()
341
341
+
.did(Did::new(&actor.did).unwrap())
342
342
+
.handle(Handle::new(&handle_string).unwrap());
343
343
+
344
344
+
if let Some(ref display_name) = actor.profile_display_name {
345
345
+
builder = builder.display_name(Some(CowStr::from(display_name.clone())));
346
346
+
}
347
347
+
348
348
+
if let Some(ref url) = avatar_url {
349
349
+
builder = builder.avatar(Some(Uri::new(url).unwrap()));
350
350
+
}
351
351
+
352
352
+
if let Some(viewer_state) = viewer {
353
353
+
builder = builder.viewer(viewer_state);
354
354
+
}
355
355
+
356
356
+
if let Some(created_at) = actor.account_created_at {
357
357
+
builder = builder.created_at(Datetime::new(created_at.fixed_offset()));
358
358
+
}
359
359
+
360
360
+
if let Some(ref pronouns) = actor.profile_pronouns {
361
361
+
builder = builder.pronouns(Some(CowStr::from(pronouns.clone())));
362
362
+
}
363
363
+
364
364
+
// Build associated data if any relevant fields are present
365
365
+
if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() {
366
366
+
let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| {
367
367
+
ProfileAssociatedChat {
368
368
+
allow_incoming: chat_type.to_string().into(),
369
369
+
..Default::default()
370
370
+
}.into_static()
371
371
+
});
372
372
+
373
373
+
let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| {
374
374
+
ProfileAssociatedActivitySubscription {
375
375
+
allow_subscriptions: sub_type.to_string().into(),
376
376
+
extra_data: None,
377
377
+
}.into_static()
378
378
+
});
379
379
+
380
380
+
let associated = ProfileAssociated {
381
381
+
chat,
382
382
+
activity_subscription,
245
383
feedgens: None,
384
384
+
lists: None,
246
385
starter_packs: None,
386
386
+
extra_data: None,
247
387
labeler: None,
248
248
-
chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| {
249
249
-
ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| {
250
250
-
ProfileAssociatedChat { allow_incoming: allow }
251
251
-
})
252
252
-
}),
253
253
-
activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| {
254
254
-
ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| {
255
255
-
ProfileAssociatedActivitySubscription { allow_subscriptions: allow }
256
256
-
})
257
257
-
}),
258
258
-
}),
388
388
+
}.into_static();
389
389
+
390
390
+
builder = builder.associated(associated);
259
391
}
392
392
+
393
393
+
builder.build().into_static()
260
394
}
261
395
262
396
/// Get ProfileViewDetailed by actor_id (direct conversion, no hydration)
263
263
-
pub async fn get_profile_view_detailed(&self, actor_id: i32) -> Option<ProfileViewDetailed> {
397
397
+
pub async fn get_profile_view_detailed(&self, actor_id: i32) -> Option<ProfileViewDetailed<'static>> {
264
398
let actor = self.get_profile_by_id(actor_id).await.ok()?;
265
399
Some(self.actor_to_profile_view_detailed(&actor))
266
400
}
267
401
268
402
/// Get multiple ProfileViewDetailed by actor_ids
269
269
-
pub async fn get_profile_views_detailed(&self, actor_ids: &[i32]) -> Vec<ProfileViewDetailed> {
403
403
+
pub async fn get_profile_views_detailed(&self, actor_ids: &[i32]) -> Vec<ProfileViewDetailed<'static>> {
270
404
let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default();
271
405
actors.iter().map(|actor| self.actor_to_profile_view_detailed(actor)).collect()
272
406
}
···
279
413
actor_ids: &[i32],
280
414
post_entity: Option<Arc<PostEntity>>,
281
415
starterpack_entity: Option<Arc<StarterpackEntity>>,
282
282
-
) -> Vec<ProfileViewDetailed> {
416
416
+
) -> Vec<ProfileViewDetailed<'static>> {
283
417
let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default();
284
418
285
419
if actors.is_empty() {
···
343
477
}
344
478
}
345
479
346
346
-
profiles
480
480
+
profiles.into_iter().map(|p| p.into_static()).collect()
347
481
}
348
482
349
483
/// Resolve identifier and get ProfileViewDetailed
350
350
-
pub async fn resolve_and_get_profile_view_detailed(&self, identifier: &str) -> Option<ProfileViewDetailed> {
484
484
+
pub async fn resolve_and_get_profile_view_detailed(&self, identifier: &str) -> Option<ProfileViewDetailed<'static>> {
351
485
let actor_id = self.resolve_identifier(identifier).await.ok()?;
352
486
self.get_profile_view_detailed(actor_id).await
353
487
}
354
488
355
489
/// Batch resolve identifiers and get ProfileViewDetailed
356
356
-
pub async fn resolve_and_get_profile_views_detailed(&self, identifiers: &[String]) -> Vec<ProfileViewDetailed> {
490
490
+
pub async fn resolve_and_get_profile_views_detailed(&self, identifiers: &[String]) -> Vec<ProfileViewDetailed<'static>> {
357
491
let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default();
358
492
self.get_profile_views_detailed(&actor_ids).await
359
493
}
···
364
498
identifiers: &[String],
365
499
post_entity: Option<Arc<PostEntity>>,
366
500
starterpack_entity: Option<Arc<StarterpackEntity>>,
367
367
-
) -> Vec<ProfileViewDetailed> {
501
501
+
) -> Vec<ProfileViewDetailed<'static>> {
368
502
let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default();
369
503
self.get_profile_views_detailed_enriched(&actor_ids, post_entity, starterpack_entity).await
370
504
}
371
505
372
506
/// Get ProfileView by actor_id
373
373
-
pub async fn get_profile_view(&self, actor_id: i32) -> Option<ProfileView> {
507
507
+
pub async fn get_profile_view(&self, actor_id: i32) -> Option<ProfileView<'static>> {
374
508
let actor = self.get_profile_by_id(actor_id).await.ok()?;
375
509
Some(self.actor_to_profile_view(&actor))
376
510
}
377
511
378
512
/// Get multiple ProfileView by actor_ids
379
379
-
pub async fn get_profile_views(&self, actor_ids: &[i32]) -> Vec<ProfileView> {
513
513
+
pub async fn get_profile_views(&self, actor_ids: &[i32]) -> Vec<ProfileView<'static>> {
380
514
let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default();
381
515
actors.iter().map(|actor| self.actor_to_profile_view(actor)).collect()
382
516
}
383
517
384
518
/// Batch resolve identifiers and get ProfileView
385
385
-
pub async fn resolve_and_get_profile_views(&self, identifiers: &[String]) -> Vec<ProfileView> {
519
519
+
pub async fn resolve_and_get_profile_views(&self, identifiers: &[String]) -> Vec<ProfileView<'static>> {
386
520
let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default();
387
521
self.get_profile_views(&actor_ids).await
388
522
}
389
523
390
524
/// Get ProfileViewBasic by actor_id
391
391
-
pub async fn get_profile_view_basic(&self, actor_id: i32, viewer_did: Option<&str>) -> Option<ProfileViewBasic> {
525
525
+
pub async fn get_profile_view_basic(&self, actor_id: i32, viewer_did: Option<&str>) -> Option<ProfileViewBasic<'static>> {
392
526
let actor = self.get_profile_by_id(actor_id).await.ok()?;
393
527
Some(self.actor_to_profile_view_basic(&actor, viewer_did).await)
394
528
}
395
529
396
530
/// Get multiple ProfileViewBasic by actor_ids
397
397
-
pub async fn get_profile_views_basic(&self, actor_ids: &[i32], viewer_did: Option<&str>) -> Vec<ProfileViewBasic> {
531
531
+
pub async fn get_profile_views_basic(&self, actor_ids: &[i32], viewer_did: Option<&str>) -> Vec<ProfileViewBasic<'static>> {
398
532
let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default();
399
533
let mut views = Vec::with_capacity(actors.len());
400
534
for actor in actors {
+48
-26
parakeet/src/entities/core/feedgen.rs
···
2
2
use diesel::prelude::*;
3
3
use diesel_async::pooled_connection::deadpool::Pool;
4
4
use diesel_async::{AsyncPgConnection, RunQueryDsl};
5
5
-
use lexica::app_bsky::feed::{GeneratorView, GeneratorViewerState};
5
5
+
use jacquard_api::app_bsky::feed::{GeneratorView, GeneratorViewerState};
6
6
use moka::future::Cache;
7
7
use std::collections::HashMap;
8
8
use std::sync::Arc;
···
65
65
pub struct FeedGeneratorEntity {
66
66
db_pool: Arc<Pool<AsyncPgConnection>>,
67
67
profile_entity: Arc<ProfileEntity>,
68
68
-
feedgen_cache: Cache<FeedGenKey, GeneratorView>,
68
68
+
feedgen_cache: Cache<FeedGenKey, GeneratorView<'static>>,
69
69
uri_to_key: Cache<String, FeedGenKey>,
70
70
config: FeedGeneratorConfig,
71
71
cdn_base: String,
···
103
103
&self,
104
104
uris: Vec<String>,
105
105
viewer_did: Option<&str>,
106
106
-
) -> Result<HashMap<String, GeneratorView>, diesel::result::Error> {
106
106
+
) -> Result<HashMap<String, GeneratorView<'static>>, diesel::result::Error> {
107
107
let mut results = HashMap::new();
108
108
let mut missing_keys = Vec::new();
109
109
···
148
148
&self,
149
149
uri: &str,
150
150
viewer_did: Option<&str>,
151
151
-
) -> Result<Option<GeneratorView>, diesel::result::Error> {
151
151
+
) -> Result<Option<GeneratorView<'static>>, diesel::result::Error> {
152
152
let results = self.get_by_uris(vec![uri.to_string()], viewer_did).await?;
153
153
Ok(results.into_iter().next().map(|(_, v)| v))
154
154
}
···
176
176
&self,
177
177
keys: &[(String, FeedGenKey)],
178
178
viewer_did: Option<&str>,
179
179
-
) -> Result<HashMap<String, GeneratorView>, diesel::result::Error> {
179
179
+
) -> Result<HashMap<String, GeneratorView<'static>>, diesel::result::Error> {
180
180
let mut conn = self.db_pool.get().await
181
181
.map_err(|e| diesel::result::Error::DatabaseError(
182
182
diesel::result::DatabaseErrorKind::UnableToSendCommand,
···
218
218
219
219
// Reconstruct the URI using the owner DID from the creator view
220
220
let uri = format!("at://{}/app.bsky.feed.generator/{}",
221
221
-
view.creator.did.clone(),
221
221
+
view.creator.did.as_str(),
222
222
rkey
223
223
);
224
224
···
233
233
&self,
234
234
data: FeedGenData,
235
235
viewer_actor_id: Option<i32>,
236
236
-
) -> Result<GeneratorView, diesel::result::Error> {
236
236
+
) -> Result<GeneratorView<'static>, diesel::result::Error> {
237
237
// Get creator profile
238
238
let creator = self.profile_entity
239
239
.get_profile_by_id(data.owner_actor_id)
···
260
260
.await
261
261
.ok();
262
262
263
263
-
viewer_did.map(|did| GeneratorViewerState {
264
264
-
like: Some(format!("at://{}/app.bsky.feed.like/TODO", did)),
263
263
+
viewer_did.and_then(|did| {
264
264
+
use jacquard_common::types::string::AtUri;
265
265
+
use jacquard_common::IntoStatic;
266
266
+
let like_uri = format!("at://{}/app.bsky.feed.like/TODO", did);
267
267
+
AtUri::new(&like_uri).ok().map(|uri| {
268
268
+
GeneratorViewerState {
269
269
+
like: Some(uri),
270
270
+
..Default::default()
271
271
+
}.into_static()
272
272
+
})
265
273
})
266
274
} else {
267
275
None
···
283
291
});
284
292
285
293
// Parse description facets
286
286
-
let description_facets = data.description_facets.and_then(|v| {
294
294
+
let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets.and_then(|v| {
287
295
serde_json::from_value(v).ok()
288
296
});
289
297
290
298
// Construct the URI
291
299
let uri = format!("at://{}/app.bsky.feed.generator/{}", creator.did, data.rkey);
292
300
293
293
-
Ok(GeneratorView {
294
294
-
uri,
295
295
-
cid: cid.unwrap_or_else(|| "".to_string()),
296
296
-
did: service_did,
297
297
-
creator: creator_view,
298
298
-
display_name: data.name.unwrap_or_else(|| "Untitled Feed".to_string()),
299
299
-
description: data.description,
300
300
-
description_facets,
301
301
-
avatar,
302
302
-
like_count: data.like_count as i64,
303
303
-
accepts_interactions: data.accepts_interactions,
304
304
-
labels: Vec::new(), // TODO: Load labels if needed
305
305
-
viewer,
306
306
-
content_mode: None, // TODO: Parse from database if needed
307
307
-
indexed_at: data.created_at,
308
308
-
})
301
301
+
use jacquard_common::types::string::{AtUri, Cid, Did, Datetime};
302
302
+
use jacquard_common::IntoStatic;
303
303
+
304
304
+
let cid_string = cid.unwrap_or_else(|| "bafyreigenerator".to_string());
305
305
+
let mut builder = GeneratorView::new()
306
306
+
.uri(AtUri::new(&uri).unwrap())
307
307
+
.cid(Cid::new(cid_string.as_bytes()).unwrap())
308
308
+
.did(Did::new(&service_did).unwrap())
309
309
+
.creator(creator_view)
310
310
+
.display_name(data.name.unwrap_or_else(|| "Untitled Feed".to_string()))
311
311
+
.like_count(Some(data.like_count as i64))
312
312
+
.indexed_at(Datetime::new(data.created_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap())));
313
313
+
314
314
+
if let Some(desc) = data.description {
315
315
+
use jacquard_common::types::string::CowStr;
316
316
+
builder = builder.description(CowStr::from(desc));
317
317
+
}
318
318
+
319
319
+
if let Some(ref av) = avatar {
320
320
+
use jacquard_common::types::string::Uri;
321
321
+
builder = builder.avatar(Uri::new(av).unwrap());
322
322
+
}
323
323
+
324
324
+
builder = builder.accepts_interactions(data.accepts_interactions);
325
325
+
326
326
+
if let Some(v) = viewer {
327
327
+
builder = builder.viewer(v);
328
328
+
}
329
329
+
330
330
+
Ok(builder.build().into_static())
309
331
}
310
332
311
333
/// Invalidate cache entries
+43
-27
parakeet/src/entities/core/list.rs
···
2
2
use diesel::prelude::*;
3
3
use diesel_async::pooled_connection::deadpool::Pool;
4
4
use diesel_async::{AsyncPgConnection, RunQueryDsl};
5
5
-
use lexica::app_bsky::graph::{ListView, ListViewerState, ListPurpose};
6
6
-
use lexica::app_bsky::richtext::FacetMain;
5
5
+
use jacquard_api::app_bsky::graph::{ListView, ListViewerState, ListPurpose};
6
6
+
use jacquard_api::app_bsky::richtext::facet::Facet;
7
7
use moka::future::Cache;
8
8
use std::collections::HashMap;
9
9
use std::sync::Arc;
···
59
59
pub struct ListEntity {
60
60
db_pool: Arc<Pool<AsyncPgConnection>>,
61
61
profile_entity: Arc<ProfileEntity>,
62
62
-
list_cache: Cache<ListKey, ListView>,
62
62
+
list_cache: Cache<ListKey, ListView<'static>>,
63
63
uri_to_key: Cache<String, ListKey>,
64
64
config: ListConfig,
65
65
cdn_base: String,
···
97
97
&self,
98
98
uris: Vec<String>,
99
99
viewer_did: Option<&str>,
100
100
-
) -> Result<HashMap<String, ListView>, diesel::result::Error> {
100
100
+
) -> Result<HashMap<String, ListView<'static>>, diesel::result::Error> {
101
101
let mut results = HashMap::new();
102
102
let mut missing_keys = Vec::new();
103
103
···
142
142
&self,
143
143
uri: &str,
144
144
viewer_did: Option<&str>,
145
145
-
) -> Result<Option<ListView>, diesel::result::Error> {
145
145
+
) -> Result<Option<ListView<'static>>, diesel::result::Error> {
146
146
let results = self.get_by_uris(vec![uri.to_string()], viewer_did).await?;
147
147
Ok(results.into_iter().next().map(|(_, v)| v))
148
148
}
···
170
170
&self,
171
171
keys: &[(String, ListKey)],
172
172
viewer_did: Option<&str>,
173
173
-
) -> Result<HashMap<String, ListView>, diesel::result::Error> {
173
173
+
) -> Result<HashMap<String, ListView<'static>>, diesel::result::Error> {
174
174
let mut conn = self.db_pool.get().await
175
175
.map_err(|e| diesel::result::Error::DatabaseError(
176
176
diesel::result::DatabaseErrorKind::UnableToSendCommand,
···
268
268
&self,
269
269
data: &T,
270
270
_viewer_actor_id: Option<i32>,
271
271
-
) -> Result<ListView, diesel::result::Error>
271
271
+
) -> Result<ListView<'static>, diesel::result::Error>
272
272
where
273
273
T: ListDataTrait,
274
274
{
···
291
291
});
292
292
293
293
// Parse description facets
294
294
-
let description_facets: Option<Vec<FacetMain>> = data.description_facets().and_then(|v| {
294
294
+
let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets().and_then(|v| {
295
295
serde_json::from_value(v.clone()).ok()
296
296
});
297
297
298
298
// Determine list purpose from list_type
299
299
let purpose = match data.list_type().as_deref() {
300
300
-
Some("moderation") | Some("app.bsky.graph.defs#modlist") => ListPurpose::ModList,
301
301
-
Some("curation") | Some("app.bsky.graph.defs#curatelist") => ListPurpose::CurateList,
302
302
-
Some("reference") | Some("app.bsky.graph.defs#referencelist") => ListPurpose::ReferenceList,
303
303
-
_ => ListPurpose::CurateList, // Default
300
300
+
Some("moderation") | Some("app.bsky.graph.defs#modlist") => ListPurpose::AppBskyGraphDefsModlist,
301
301
+
Some("curation") | Some("app.bsky.graph.defs#curatelist") => ListPurpose::AppBskyGraphDefsCuratelist,
302
302
+
Some("reference") | Some("app.bsky.graph.defs#referencelist") => ListPurpose::AppBskyGraphDefsReferencelist,
303
303
+
_ => ListPurpose::AppBskyGraphDefsCuratelist, // Default
304
304
};
305
305
306
306
// Construct the URI
···
312
312
313
313
// Build viewer state if applicable
314
314
// TODO: Implement mute/block status checking
315
315
-
let viewer = None;
315
315
+
let viewer: Option<jacquard_api::app_bsky::graph::ListViewerState> = None;
316
316
317
317
-
Ok(ListView {
318
318
-
uri,
319
319
-
cid: cid.unwrap_or_else(|| "".to_string()),
320
320
-
name: data.name().unwrap_or_else(|| "Untitled List".to_string()),
321
321
-
creator: creator_view,
322
322
-
purpose,
323
323
-
description: data.description(),
324
324
-
description_facets,
325
325
-
avatar,
326
326
-
list_item_count: data.item_count() as i64,
327
327
-
viewer,
328
328
-
labels: Vec::new(), // TODO: Load labels if needed
329
329
-
indexed_at,
330
330
-
})
317
317
+
use jacquard_common::types::string::{AtUri, Cid, Datetime};
318
318
+
use jacquard_common::IntoStatic;
319
319
+
320
320
+
let cid_string = cid.unwrap_or_else(|| "bafyreilist".to_string());
321
321
+
let mut builder = ListView::new()
322
322
+
.uri(AtUri::new(&uri).unwrap())
323
323
+
.cid(Cid::new(cid_string.as_bytes()).unwrap())
324
324
+
.name(data.name().unwrap_or_else(|| "Untitled List".to_string()))
325
325
+
.creator(creator_view)
326
326
+
.purpose(purpose)
327
327
+
.list_item_count(Some(data.item_count() as i64))
328
328
+
.indexed_at(Datetime::new(indexed_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap())));
329
329
+
330
330
+
if let Some(desc) = data.description() {
331
331
+
use jacquard_common::types::string::CowStr;
332
332
+
builder = builder.description(CowStr::from(desc));
333
333
+
}
334
334
+
335
335
+
if let Some(ref av) = avatar {
336
336
+
use jacquard_common::types::string::Uri;
337
337
+
builder = builder.avatar(Uri::new(av).unwrap());
338
338
+
}
339
339
+
340
340
+
if let Some(v) = viewer {
341
341
+
builder = builder.viewer(v);
342
342
+
}
343
343
+
344
344
+
// TODO: Add description_facets and labels when supported
345
345
+
346
346
+
Ok(builder.build().into_static())
331
347
}
332
348
333
349
/// Invalidate cache entries
+69
-89
parakeet/src/entities/core/post.rs
···
5
5
RunQueryDsl,
6
6
};
7
7
use eyre::Result;
8
8
-
use lexica::app_bsky::feed::{PostView, PostViewerState};
9
9
-
use lexica::app_bsky::RecordStats;
10
10
-
use lexica::app_bsky::embed::{Embed, ImageView, External};
11
11
-
use lexica::StrongRef;
8
8
+
use jacquard_api::app_bsky::feed::{PostView, ViewerState as PostViewerState};
9
9
+
use jacquard_api::com_atproto::repo::strong_ref::StrongRef;
10
10
+
use jacquard_common::types::string::{AtUri, Cid, Datetime};
11
11
+
use jacquard_common::types::value::Data;
12
12
+
use jacquard_common::IntoStatic;
12
13
use parakeet_db::models::{Post, Actor};
13
14
use std::sync::Arc;
14
15
use std::collections::HashMap;
···
66
67
67
68
impl PostData {
68
69
/// Convert this post data to a StrongRef (AT URI + CID)
69
69
-
pub fn to_strong_ref(&self) -> Option<StrongRef> {
70
70
+
pub fn to_strong_ref(&self) -> Option<StrongRef<'static>> {
70
71
// Convert the CID bytes to the proper string format
71
72
let cid_str = parakeet_db::cid_util::digest_to_record_cid_string(&self.post.cid)?;
72
73
···
77
78
parakeet_db::tid_util::encode_tid(self.post.rkey)
78
79
);
79
80
80
80
-
StrongRef::new_from_str(uri, &cid_str).ok()
81
81
+
StrongRef::new_from_str(uri, &cid_str).ok().map(|sr| sr.into_static())
81
82
}
82
83
}
83
84
···
170
171
}
171
172
172
173
/// Get a post as a StrongRef by actor_id and rkey
173
173
-
pub async fn get_post_strong_ref(&self, actor_id: i32, rkey: i64) -> Result<Option<StrongRef>> {
174
174
+
pub async fn get_post_strong_ref(&self, actor_id: i32, rkey: i64) -> Result<Option<StrongRef<'static>>> {
174
175
let post_data = self.get_post_by_key(actor_id, rkey).await?;
175
176
Ok(post_data.to_strong_ref())
176
177
}
···
429
430
&self,
430
431
uri: &str,
431
432
viewer_did: Option<&str>,
432
432
-
) -> Result<Option<(PostView, Option<lexica::app_bsky::feed::ReplyRef>)>, diesel::result::Error> {
433
433
+
) -> Result<Option<(PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)>, diesel::result::Error> {
433
434
// Resolve URI to key
434
435
let key = match self.resolve_uri(uri).await {
435
436
Ok(k) => k,
···
480
481
&self,
481
482
uris: Vec<String>,
482
483
viewer_did: Option<&str>,
483
483
-
) -> Result<HashMap<String, (PostView, Option<lexica::app_bsky::feed::ReplyRef>)>, diesel::result::Error> {
484
484
+
) -> Result<HashMap<String, (PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)>, diesel::result::Error> {
484
485
let mut results = HashMap::new();
485
486
486
487
for uri in uris {
···
591
592
};
592
593
593
594
Some(PostViewerState {
594
594
-
repost: repost_uri,
595
595
-
like: like_uri,
596
596
-
bookmarked,
597
597
-
thread_muted: false,
598
598
-
reply_disabled,
599
599
-
embedding_disabled: false,
600
600
-
})
595
595
+
repost: repost_uri.as_ref().and_then(|u| AtUri::new(u).ok()),
596
596
+
like: like_uri.as_ref().and_then(|u| AtUri::new(u).ok()),
597
597
+
bookmarked: Some(bookmarked),
598
598
+
pinned: None,
599
599
+
reply_disabled: Some(reply_disabled),
600
600
+
thread_muted: Some(false),
601
601
+
embedding_disabled: Some(false),
602
602
+
..Default::default()
603
603
+
}.into_static())
601
604
} else {
602
605
None
603
606
};
604
607
605
605
-
PostView {
606
606
-
uri,
607
607
-
cid,
608
608
-
author: self.profile_entity.actor_to_profile_view_basic(&data.author, viewer_did).await,
609
609
-
record: serde_json::json!({
610
610
-
"$type": "app.bsky.feed.post",
611
611
-
"text": text,
612
612
-
"createdAt": created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
613
613
-
}),
614
614
-
embed: self.build_embed(&data),
615
615
-
stats: RecordStats {
616
616
-
reply_count: data.post.reply_count as i64,
617
617
-
repost_count: data.post.repost_count as i64,
618
618
-
like_count: data.post.like_count as i64,
619
619
-
quote_count: data.post.quote_count as i64,
620
620
-
bookmark_count: 0, // Bookmarks are tracked separately
621
621
-
},
622
622
-
indexed_at: created_at, // Use created_at as indexed_at
623
623
-
viewer,
624
624
-
labels: Vec::new(),
625
625
-
threadgate: self.build_threadgate(&data)
626
626
-
}
608
608
+
let author = self.profile_entity.actor_to_profile_view_basic(&data.author, viewer_did).await;
609
609
+
610
610
+
PostView::new()
611
611
+
.uri(AtUri::new(&uri).unwrap())
612
612
+
.cid(Cid::new(cid.as_bytes()).unwrap())
613
613
+
.author(author)
614
614
+
.record({
615
615
+
// TODO: Properly convert to Data type
616
616
+
// For now, serialize to string then parse back
617
617
+
let record_json = serde_json::json!({
618
618
+
"$type": "app.bsky.feed.post",
619
619
+
"text": text,
620
620
+
"createdAt": created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
621
621
+
});
622
622
+
// Parse as Data directly from Value
623
623
+
serde_json::from_value::<Data>(record_json).unwrap()
624
624
+
})
625
625
+
.embed(self.build_embed(&data))
626
626
+
.reply_count(Some(data.post.reply_count as i64))
627
627
+
.repost_count(Some(data.post.repost_count as i64))
628
628
+
.like_count(Some(data.post.like_count as i64))
629
629
+
.quote_count(Some(data.post.quote_count as i64))
630
630
+
.bookmark_count(Some(0))
631
631
+
.indexed_at(Datetime::new(created_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap())))
632
632
+
.viewer(viewer)
633
633
+
.labels(Vec::new())
634
634
+
.threadgate(self.build_threadgate(&data))
635
635
+
.build()
636
636
+
.into_static()
627
637
}
628
638
629
639
/// Build threadgate from post data
630
630
-
fn build_threadgate(&self, data: &PostData) -> Option<lexica::app_bsky::feed::ThreadgateView> {
631
631
-
use lexica::app_bsky::feed::ThreadgateView;
632
632
-
633
633
-
// Check if threadgate exists
634
634
-
if data.post.threadgate_allow.is_none() {
635
635
-
return None;
636
636
-
}
637
637
-
638
638
-
let uri = format!(
639
639
-
"at://{}/app.bsky.feed.threadgate/{}",
640
640
-
data.author.did,
641
641
-
parakeet_db::tid_util::encode_tid(data.post.rkey)
642
642
-
);
643
643
-
644
644
-
// Generate CID for threadgate
645
645
-
let cid = "bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq".to_string();
646
646
-
647
647
-
// Build allow rules from threadgate_allow array
648
648
-
let mut allow = Vec::new();
649
649
-
if let Some(ref rules) = data.post.threadgate_allow {
650
650
-
// Parse rules from the array
651
651
-
// This would need proper implementation based on the actual rule format
652
652
-
allow.push(serde_json::json!({
653
653
-
"$type": "app.bsky.feed.threadgate#mentionRule"
654
654
-
}));
655
655
-
}
640
640
+
fn build_threadgate(&self, data: &PostData) -> Option<jacquard_api::app_bsky::feed::ThreadgateView<'static>> {
656
641
657
657
-
// Create the record JSON
658
658
-
let record = serde_json::json!({
659
659
-
"$type": "app.bsky.feed.threadgate",
660
660
-
"post": format!("at://{}/app.bsky.feed.post/{}",
661
661
-
data.author.did,
662
662
-
parakeet_db::tid_util::encode_tid(data.post.rkey)
663
663
-
),
664
664
-
"allow": allow,
665
665
-
"createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
666
666
-
});
667
667
-
668
668
-
Some(ThreadgateView {
669
669
-
uri,
670
670
-
cid,
671
671
-
record,
672
672
-
lists: vec![],
673
673
-
})
642
642
+
// TODO: Implement threadgate support with jacquard types
643
643
+
None
674
644
}
675
645
676
646
/// Build embed from post data
677
677
-
fn build_embed(&self, data: &PostData) -> Option<Embed> {
647
647
+
fn build_embed(&self, data: &PostData) -> Option<jacquard_api::app_bsky::feed::PostViewEmbed<'static>> {
648
648
+
// TODO: Implement embed support with jacquard types
649
649
+
None
650
650
+
651
651
+
/* Original embed logic to be migrated:
678
652
use parakeet_db::types::EmbedType;
679
653
680
654
match data.post.embed_type {
···
693
667
data.author.did, cid_str),
694
668
alt: img.alt.clone().unwrap_or_default(),
695
669
aspect_ratio: if let (Some(w), Some(h)) = (img.width, img.height) {
696
696
-
Some(lexica::app_bsky::embed::AspectRatio {
670
670
+
Some(jacquard_api::app_bsky::embed::AspectRatio {
697
671
width: w,
698
672
height: h,
699
673
})
···
737
711
},
738
712
None => None,
739
713
}
714
714
+
*/
740
715
}
741
716
742
717
/// Build reply context for a post
···
744
719
&self,
745
720
post_data: &PostData,
746
721
viewer_did: Option<&str>,
747
747
-
) -> Option<lexica::app_bsky::feed::ReplyRef> {
748
748
-
use lexica::app_bsky::feed::{ReplyRef, ReplyRefPost};
722
722
+
) -> Option<jacquard_api::app_bsky::feed::ReplyRef<'static>> {
723
723
+
// TODO: Implement reply context with jacquard types
724
724
+
return None;
725
725
+
726
726
+
/* Original logic to be migrated:
727
727
+
use jacquard_api::app_bsky::feed::{ReplyRef, ReplyRefPost};
749
728
750
729
// Check if this post has parent/root references
751
730
if post_data.post.parent_post_actor_id.is_none() || post_data.post.parent_post_rkey.is_none() {
···
791
770
if root_actor_id == parent_actor_id && root_rkey == parent_rkey {
792
771
// Root is same as parent, create a new ReplyRefPost with same data
793
772
match &parent_post {
794
794
-
lexica::app_bsky::feed::ReplyRefPost::Post(view) => {
795
795
-
lexica::app_bsky::feed::ReplyRefPost::Post(view.clone())
773
773
+
jacquard_api::app_bsky::feed::ReplyRefPost::Post(view) => {
774
774
+
jacquard_api::app_bsky::feed::ReplyRefPost::Post(view.clone())
796
775
},
797
776
_ => return None, // Parent should be a Post
798
777
}
···
834
813
} else {
835
814
// No root specified, parent is the root
836
815
match &parent_post {
837
837
-
lexica::app_bsky::feed::ReplyRefPost::Post(view) => {
838
838
-
lexica::app_bsky::feed::ReplyRefPost::Post(view.clone())
816
816
+
jacquard_api::app_bsky::feed::ReplyRefPost::Post(view) => {
817
817
+
jacquard_api::app_bsky::feed::ReplyRefPost::Post(view.clone())
839
818
},
840
819
_ => return None, // Parent should be a Post
841
820
}
···
849
828
parent: parent_post,
850
829
grandparent_author,
851
830
})
831
831
+
*/
852
832
}
853
833
854
834
/// Get likes for a post
+66
-47
parakeet/src/entities/core/starterpack.rs
···
3
3
use diesel::prelude::*;
4
4
use diesel_async::pooled_connection::deadpool::Pool;
5
5
use diesel_async::{AsyncPgConnection, RunQueryDsl};
6
6
-
use lexica::app_bsky::graph::{StarterPackView, StarterPackViewBasic, ListViewBasic, ListItemView};
7
7
-
use lexica::app_bsky::richtext::FacetMain;
6
6
+
use jacquard_api::app_bsky::graph::{StarterPackView, StarterPackViewBasic, ListViewBasic, ListItemView};
7
7
+
use jacquard_api::app_bsky::richtext::facet::Facet;
8
8
+
use jacquard_common::types::aturi::AtUri;
9
9
+
use jacquard_common::types::string::Cid;
8
10
use moka::future::Cache;
9
11
use std::collections::HashMap;
10
12
use std::sync::Arc;
···
63
65
profile_entity: Arc<ProfileEntity>,
64
66
list_entity: Arc<ListEntity>,
65
67
feedgen_entity: Arc<crate::entities::core::feedgen::FeedGeneratorEntity>,
66
66
-
starterpack_cache: Cache<StarterpackKey, StarterPackViewBasic>,
68
68
+
starterpack_cache: Cache<StarterpackKey, StarterPackViewBasic<'static>>,
67
69
uri_to_key: Cache<String, StarterpackKey>,
68
70
config: StarterpackConfig,
69
71
}
···
249
251
let owner_did = self.profile_entity
250
252
.get_did_by_id(view.creator.did.parse::<i32>().unwrap_or(0))
251
253
.await
252
252
-
.unwrap_or_else(|_| view.creator.did.clone());
254
254
+
.unwrap_or_else(|_| view.creator.did.to_string());
253
255
254
256
let rkey_str = parakeet_db::tid_util::encode_tid(keys.iter()
255
257
.find(|(_, k)| k.actor_id == actor_id)
···
274
276
.await
275
277
.map_err(|_| diesel::result::Error::NotFound)?;
276
278
277
277
-
// Convert to ProfileViewBasic using profile_converter
278
278
-
let creator_view = lexica::app_bsky::actor::ProfileViewBasic {
279
279
-
did: creator.did.clone(),
280
280
-
handle: creator.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()),
281
281
-
display_name: creator.profile_display_name.clone(),
282
282
-
avatar: creator.profile_avatar_cid.as_ref()
283
283
-
.and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid)
284
284
-
.map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", creator.did, cid_str))),
285
285
-
associated: None,
286
286
-
viewer: None,
287
287
-
labels: Vec::new(),
288
288
-
created_at: creator.account_created_at.unwrap_or_else(|| chrono::Utc::now()),
289
289
-
pronouns: creator.profile_pronouns.clone(),
290
290
-
status: None,
291
291
-
verification: None,
292
292
-
};
279
279
+
// Convert to ProfileViewBasic using ProfileEntity
280
280
+
let creator_view = self.profile_entity.actor_to_profile_view_basic(&creator, None).await;
293
281
294
282
// Convert CID
295
283
let cid = parakeet_db::cid_util::digest_to_blob_cid_string(&data.cid);
296
284
297
285
// Parse description facets
298
298
-
let description_facets: Option<Vec<FacetMain>> = data.description_facets.and_then(|v| {
286
286
+
let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets.and_then(|v| {
299
287
serde_json::from_value(v).ok()
300
288
});
301
289
···
332
320
(0, 0, 0)
333
321
};
334
322
323
323
+
use jacquard_common::IntoStatic;
324
324
+
335
325
Ok(StarterPackViewBasic {
336
336
-
uri,
337
337
-
cid: cid.unwrap_or_else(|| "".to_string()),
338
338
-
record,
326
326
+
uri: AtUri::new(&uri).unwrap(),
327
327
+
cid: Cid::new(cid.unwrap_or_else(|| "".to_string()).as_bytes()).unwrap(),
328
328
+
record: serde_json::from_value(record).unwrap(),
339
329
creator: creator_view,
340
340
-
list_item_count,
341
341
-
joined_week_count,
342
342
-
joined_all_time_count,
343
343
-
labels: Vec::new(),
344
344
-
indexed_at,
345
345
-
})
330
330
+
list_item_count: Some(list_item_count),
331
331
+
joined_week_count: Some(joined_week_count),
332
332
+
joined_all_time_count: Some(joined_all_time_count),
333
333
+
labels: Some(Vec::new()),
334
334
+
indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()),
335
335
+
extra_data: None,
336
336
+
}.into_static())
346
337
}
347
338
348
339
/// Build a full StarterPackView from raw data
···
376
367
list_item_count: full_list.list_item_count,
377
368
viewer: full_list.viewer,
378
369
labels: full_list.labels,
379
379
-
indexed_at: full_list.indexed_at,
370
370
+
indexed_at: Some(full_list.indexed_at),
371
371
+
extra_data: None,
380
372
})
381
373
} else {
382
374
None
···
424
416
Vec::new()
425
417
};
426
418
427
427
-
Ok(StarterPackView {
428
428
-
uri: basic.uri,
429
429
-
cid: basic.cid,
430
430
-
record: basic.record,
431
431
-
creator: basic.creator,
432
432
-
list,
433
433
-
list_items_sample,
434
434
-
feeds,
435
435
-
list_item_count: basic.list_item_count,
436
436
-
joined_week_count: basic.joined_week_count,
437
437
-
joined_all_time_count: basic.joined_all_time_count,
438
438
-
labels: basic.labels,
439
439
-
indexed_at: basic.indexed_at,
440
440
-
})
419
419
+
use jacquard_common::IntoStatic;
420
420
+
421
421
+
let mut builder = StarterPackView::new()
422
422
+
.uri(basic.uri)
423
423
+
.cid(basic.cid)
424
424
+
.record(basic.record)
425
425
+
.creator(basic.creator)
426
426
+
.indexed_at(basic.indexed_at);
427
427
+
428
428
+
if let Some(l) = list {
429
429
+
builder = builder.list(l);
430
430
+
}
431
431
+
432
432
+
if !list_items_sample.is_empty() {
433
433
+
builder = builder.list_items_sample(list_items_sample);
434
434
+
}
435
435
+
436
436
+
if !feeds.is_empty() {
437
437
+
builder = builder.feeds(feeds);
438
438
+
}
439
439
+
440
440
+
// list_item_count is not a builder method in jacquard
441
441
+
// This may need to be handled differently
442
442
+
443
443
+
if let Some(count) = basic.joined_week_count {
444
444
+
builder = builder.joined_week_count(count);
445
445
+
}
446
446
+
447
447
+
if let Some(count) = basic.joined_all_time_count {
448
448
+
builder = builder.joined_all_time_count(count);
449
449
+
}
450
450
+
451
451
+
if let Some(ref labels) = basic.labels {
452
452
+
if !labels.is_empty() {
453
453
+
builder = builder.labels(labels.clone());
454
454
+
}
455
455
+
}
456
456
+
457
457
+
Ok(builder.build().into_static())
441
458
}
442
459
443
460
/// Invalidate cache entries
···
718
735
parakeet_db::tid_util::encode_tid(item_rkey));
719
736
720
737
let subject = self.profile_entity.actor_to_profile_view(subject_actor);
738
738
+
use jacquard_common::IntoStatic;
721
739
list_items.push(ListItemView {
722
722
-
uri: item_uri,
740
740
+
uri: jacquard_common::types::aturi::AtUri::new(&item_uri).unwrap(),
723
741
subject,
724
724
-
});
742
742
+
extra_data: None,
743
743
+
}.into_static());
725
744
}
726
745
}
727
746
}
+1
parakeet/src/lib.rs
···
26
26
27
27
// Inline module declarations for entities (replacing entities/mod.rs)
28
28
pub mod entities;
29
29
+
pub mod views;
29
30
30
31
pub mod xrpc;
31
32
+80
-103
parakeet/src/xrpc/app_bsky/actor.rs
···
1
1
use crate::common::errors::{Error, XrpcResult};
2
2
use crate::common::auth::{AtpAcceptLabelers, AtpAuth};
3
3
use crate::GlobalState;
4
4
-
use axum::extract::{Query, State};
5
5
-
use axum::response::{IntoResponse as _, Response};
4
4
+
use axum::extract::State;
5
5
+
use axum::http::StatusCode;
6
6
+
use axum::response::IntoResponse;
6
7
use axum::Json;
7
7
-
use axum_extra::extract::Query as ExtraQuery;
8
8
-
use lexica::app_bsky::actor::{
8
8
+
use jacquard_axum::ExtractXrpc;
9
9
+
use jacquard_api::app_bsky::actor::{
9
10
ProfileView, ProfileViewBasic, ProfileViewDetailed,
10
10
-
GetProfilesResponse, GetProfilesParams,
11
11
-
SearchActorsResponse, SearchActorsParams,
12
12
-
SearchActorsTypeaheadResponse, SearchActorsTypeaheadParams,
13
13
-
GetSuggestionsResponse, GetSuggestionsParams,
14
11
};
15
15
-
use serde::{Deserialize, Serialize};
16
16
-
use std::collections::HashMap;
17
17
-
18
18
-
#[derive(Debug, Deserialize)]
19
19
-
pub struct ActorQuery {
20
20
-
pub actor: String,
21
21
-
}
12
12
+
use jacquard_api::app_bsky::actor::get_profile::{GetProfileRequest, GetProfileOutput};
13
13
+
use jacquard_api::app_bsky::actor::get_profiles::{GetProfilesRequest, GetProfilesOutput};
14
14
+
use jacquard_api::app_bsky::actor::search_actors::{SearchActorsRequest, SearchActorsOutput};
15
15
+
use jacquard_api::app_bsky::actor::search_actors_typeahead::{SearchActorsTypeaheadRequest, SearchActorsTypeaheadOutput};
16
16
+
use jacquard_api::app_bsky::actor::get_suggestions::{GetSuggestionsRequest, GetSuggestionsOutput};
17
17
+
use jacquard_common::IntoStatic;
22
18
23
19
/// Handles the app.bsky.actor.getProfile endpoint
24
20
///
25
25
-
/// Fetches a user's profile from our database using ProfileEntity with enriched fields.
21
21
+
/// Uses ExtractXrpc for proper XRPC request handling
26
22
pub async fn get_profile(
27
23
State(state): State<GlobalState>,
28
28
-
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
24
24
+
ExtractXrpc(req): ExtractXrpc<GetProfileRequest>,
25
25
+
_labelers: AtpAcceptLabelers,
29
26
_maybe_auth: Option<AtpAuth>,
30
30
-
Query(query): Query<ActorQuery>,
31
31
-
) -> XrpcResult<Response> {
27
27
+
) -> Result<Json<GetProfileOutput<'static>>, StatusCode> {
32
28
// First resolve the actor ID
33
29
let actor_id = state.profile_entity
34
34
-
.resolve_identifier(&query.actor)
30
30
+
.resolve_identifier(req.actor.as_ref())
35
31
.await
36
36
-
.map_err(|_| Error::not_found())?;
32
32
+
.map_err(|_| StatusCode::NOT_FOUND)?;
37
33
38
34
// Get the actor data
39
35
let actor = state.profile_entity
40
36
.get_profile_by_id(actor_id)
41
37
.await
42
42
-
.map_err(|_| Error::not_found())?;
38
38
+
.map_err(|_| StatusCode::NOT_FOUND)?;
43
39
44
40
// Convert with enriched fields (pinned post, starter pack)
45
41
let profile = state.profile_entity
···
50
46
)
51
47
.await;
52
48
53
53
-
Ok(Json(profile).into_response())
49
49
+
Ok(Json(GetProfileOutput {
50
50
+
value: profile,
51
51
+
extra_data: None,
52
52
+
}))
54
53
}
55
55
-
56
54
57
55
/// Handles the app.bsky.actor.getProfiles endpoint
58
56
///
59
59
-
/// Returns detailed profile information for multiple actors using ProfileEntity with direct conversion.
57
57
+
/// Returns detailed profile information for multiple actors
60
58
pub async fn get_profiles(
61
59
State(state): State<GlobalState>,
62
62
-
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
60
60
+
ExtractXrpc(req): ExtractXrpc<GetProfilesRequest>,
61
61
+
_labelers: AtpAcceptLabelers,
63
62
_maybe_auth: Option<AtpAuth>,
64
64
-
ExtraQuery(query): ExtraQuery<GetProfilesParams>,
65
65
-
) -> XrpcResult<Response> {
63
63
+
) -> Result<Json<GetProfilesOutput<'static>>, StatusCode> {
66
64
// Use ProfileEntity with enriched conversion to include pinned posts
65
65
+
let identifiers: Vec<String> = req.actors.iter().map(|id| id.as_str().to_string()).collect();
67
66
let profiles = state.profile_entity
68
67
.resolve_and_get_profile_views_detailed_enriched(
69
69
-
&query.actors,
68
68
+
&identifiers,
70
69
Some(state.post_entity.clone()),
71
70
Some(state.starterpack_entity.clone()),
72
71
)
73
72
.await;
74
73
75
75
-
Ok(Json(GetProfilesResponse { profiles }).into_response())
74
74
+
Ok(Json(GetProfilesOutput {
75
75
+
profiles,
76
76
+
extra_data: None,
77
77
+
}))
76
78
}
77
79
78
80
// ============================================================================
···
81
83
82
84
/// Handles the app.bsky.actor.searchActors endpoint
83
85
///
84
84
-
/// Full-text search across handles, display names, and descriptions.
85
85
-
/// Uses trigram similarity + full-text search with ranking.
86
86
+
/// Full-text search across handles, display names, and descriptions
86
87
pub async fn search_actors(
87
88
State(state): State<GlobalState>,
88
88
-
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
89
89
+
ExtractXrpc(req): ExtractXrpc<SearchActorsRequest>,
90
90
+
_labelers: AtpAcceptLabelers,
89
91
_maybe_auth: Option<AtpAuth>,
90
90
-
Query(query): Query<SearchActorsParams>,
91
91
-
) -> XrpcResult<Response> {
92
92
+
) -> Result<Json<SearchActorsOutput<'static>>, StatusCode> {
92
93
// Get search term from either q or term parameter
93
93
-
let search_term = query.q.or(query.term).ok_or_else(|| {
94
94
-
Error::new(
95
95
-
axum::http::StatusCode::BAD_REQUEST,
96
96
-
"InvalidRequest",
97
97
-
Some("Either 'q' or 'term' parameter is required".to_owned()),
98
98
-
)
99
99
-
})?;
94
94
+
let search_term = req.q.or(req.term).ok_or(StatusCode::BAD_REQUEST)?;
100
95
101
96
// Validate query is not empty/whitespace
102
97
let trimmed = search_term.trim();
103
98
if trimmed.is_empty() {
104
104
-
return Err(Error::new(
105
105
-
axum::http::StatusCode::BAD_REQUEST,
106
106
-
"InvalidRequest",
107
107
-
Some("Query string cannot be empty".to_owned()),
108
108
-
));
99
99
+
return Err(StatusCode::BAD_REQUEST);
109
100
}
110
101
111
111
-
let limit = query.limit.unwrap_or(25).clamp(1, 100);
102
102
+
let limit = req.limit.unwrap_or(25).clamp(1, 100);
112
103
113
104
// Parse cursor (rank value as float)
114
114
-
let cursor_rank = query
105
105
+
let cursor_rank = req
115
106
.cursor
116
107
.as_ref()
117
108
.and_then(|c| c.parse::<f64>().ok());
···
119
110
// Execute search query using ProfileEntity
120
111
let results = state.profile_entity.search_actors(trimmed, (limit + 1) as i64, cursor_rank)
121
112
.await
122
122
-
.map_err(|e| {
123
123
-
Error::new(
124
124
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
125
125
-
"DatabaseError",
126
126
-
Some(format!("Search query failed: {}", e)),
127
127
-
)
128
128
-
})?;
113
113
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
129
114
130
115
// Check for pagination
131
116
let has_more = results.len() > limit as usize;
···
148
133
None
149
134
};
150
135
151
151
-
Ok(Json(SearchActorsResponse { actors, cursor }).into_response())
136
136
+
Ok(Json(SearchActorsOutput {
137
137
+
actors,
138
138
+
cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)),
139
139
+
extra_data: None,
140
140
+
}))
152
141
}
153
142
154
143
/// Handles the app.bsky.actor.searchActorsTypeahead endpoint
155
144
///
156
156
-
/// Fast prefix-based search for autocomplete.
157
157
-
/// Searches handle and displayName only, no pagination.
145
145
+
/// Fast prefix-based search for autocomplete
158
146
pub async fn search_actors_typeahead(
159
147
State(state): State<GlobalState>,
160
160
-
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
148
148
+
ExtractXrpc(req): ExtractXrpc<SearchActorsTypeaheadRequest>,
149
149
+
_labelers: AtpAcceptLabelers,
161
150
maybe_auth: Option<AtpAuth>,
162
162
-
Query(query): Query<SearchActorsTypeaheadParams>,
163
163
-
) -> XrpcResult<Response> {
151
151
+
) -> Result<Json<SearchActorsTypeaheadOutput<'static>>, StatusCode> {
164
152
// Get search term
165
165
-
let search_term = query.q.or(query.term).ok_or_else(|| {
166
166
-
Error::new(
167
167
-
axum::http::StatusCode::BAD_REQUEST,
168
168
-
"InvalidRequest",
169
169
-
Some("Either 'q' or 'term' parameter is required".to_owned()),
170
170
-
)
171
171
-
})?;
153
153
+
let search_term = req.q.or(req.term).ok_or(StatusCode::BAD_REQUEST)?;
172
154
173
155
// Validate and lowercase for case-insensitive search
174
156
let trimmed = search_term.trim().to_lowercase();
175
157
if trimmed.is_empty() {
176
176
-
return Err(Error::new(
177
177
-
axum::http::StatusCode::BAD_REQUEST,
178
178
-
"InvalidRequest",
179
179
-
Some("Query string cannot be empty".to_owned()),
180
180
-
));
158
158
+
return Err(StatusCode::BAD_REQUEST);
181
159
}
182
160
183
161
// Typeahead typically uses smaller limit
184
184
-
let limit = query.limit.unwrap_or(10).clamp(1, 25);
162
162
+
let limit = req.limit.unwrap_or(10).clamp(1, 25);
185
163
186
164
// Execute typeahead query (no cursor - fixed limit) using ProfileEntity
187
165
let results = state.profile_entity.search_actors_typeahead(&trimmed, limit as i64)
188
166
.await
189
189
-
.map_err(|e| {
190
190
-
Error::new(
191
191
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
192
192
-
"DatabaseError",
193
193
-
Some(format!("Typeahead query failed: {}", e)),
194
194
-
)
195
195
-
})?;
167
167
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
196
168
197
169
// Get ProfileViewBasic for each actor_id (typeahead returns basic views)
198
170
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.as_str());
···
200
172
.get_profile_views_basic(&results, viewer_did)
201
173
.await;
202
174
203
203
-
Ok(Json(SearchActorsTypeaheadResponse { actors }).into_response())
175
175
+
Ok(Json(SearchActorsTypeaheadOutput {
176
176
+
actors,
177
177
+
extra_data: None,
178
178
+
}))
204
179
}
205
180
206
181
// ============================================================================
···
209
184
210
185
/// Handles the app.bsky.actor.getSuggestions endpoint
211
186
///
212
212
-
/// Returns popular, high-quality accounts for discovery and onboarding.
213
213
-
/// Ranks by follower count with quality filters (has profile, active status).
187
187
+
/// Returns popular, high-quality accounts for discovery and onboarding
214
188
pub async fn get_suggestions(
215
189
State(state): State<GlobalState>,
216
216
-
AtpAcceptLabelers(labelers): AtpAcceptLabelers,
190
190
+
ExtractXrpc(req): ExtractXrpc<GetSuggestionsRequest>,
191
191
+
_labelers: AtpAcceptLabelers,
217
192
maybe_auth: Option<AtpAuth>,
218
218
-
Query(query): Query<GetSuggestionsParams>,
219
219
-
) -> XrpcResult<Response> {
220
220
-
let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize;
221
221
-
let offset = query
193
193
+
) -> Result<Json<GetSuggestionsOutput<'static>>, StatusCode> {
194
194
+
let limit = req.limit.unwrap_or(25).clamp(1, 100) as usize;
195
195
+
let offset = req
222
196
.cursor
223
197
.as_ref()
224
198
.and_then(|c| c.parse::<usize>().ok())
225
199
.unwrap_or(0);
226
200
227
227
-
// Compute global suggestions
228
228
-
// TODO: Consider adding moka cache if this becomes a bottleneck
229
229
-
230
201
// Get top 1000 most-followed DIDs using ProfileEntity
231
202
let all_dids = state.profile_entity.get_top_followed_actors(1000)
232
203
.await
233
233
-
.map_err(|e| Error::server_error(Some(&e.to_string())))?;
204
204
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
234
205
235
206
if all_dids.is_empty() {
236
236
-
return Ok(Json(GetSuggestionsResponse {
207
207
+
return Ok(Json(GetSuggestionsOutput {
237
208
actors: Vec::new(),
238
209
cursor: None,
239
239
-
})
240
240
-
.into_response());
210
210
+
rec_id: None,
211
211
+
extra_data: None,
212
212
+
}));
241
213
}
242
214
243
215
// Convert DIDs to actor_ids for efficient profile loading using ProfileEntity
···
259
231
.unwrap_or_default();
260
232
261
233
// Create actor_id to Actor map
262
262
-
let profiles_by_id: HashMap<i32, _> = profiles
234
234
+
let profiles_by_id: std::collections::HashMap<i32, _> = profiles
263
235
.into_iter()
264
236
.map(|actor| (actor.id, actor))
265
237
.collect();
···
322
294
.await;
323
295
324
296
// Create map for order preservation
325
325
-
let mut profiles_map: HashMap<String, ProfileView> = profiles_vec
297
297
+
let mut profiles_map: std::collections::HashMap<String, ProfileView<'static>> = profiles_vec
326
298
.into_iter()
327
327
-
.map(|profile| (profile.did.clone(), profile))
299
299
+
.map(|profile| (profile.did.to_string(), profile))
328
300
.collect();
329
301
330
302
// Maintain pagination order
331
331
-
let actors: Vec<ProfileView> = page_dids
303
303
+
let actors: Vec<ProfileView<'static>> = page_dids
332
304
.into_iter()
333
305
.filter_map(|did| profiles_map.remove(&did))
334
306
.collect();
335
307
336
336
-
Ok(Json(GetSuggestionsResponse { actors, cursor }).into_response())
337
337
-
}
308
308
+
Ok(Json(GetSuggestionsOutput {
309
309
+
actors,
310
310
+
cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)),
311
311
+
rec_id: None,
312
312
+
extra_data: None,
313
313
+
}))
314
314
+
}
+68
-97
parakeet/src/xrpc/app_bsky/bookmark.rs
···
1
1
-
use crate::common::errors::XrpcResult;
2
1
use crate::common::auth::{AtpAcceptLabelers, AtpAuth};
3
3
-
use crate::xrpc::{datetime_cursor, CursorQuery};
2
2
+
use crate::xrpc::datetime_cursor;
4
3
use crate::GlobalState;
5
5
-
use axum::extract::{Query, State};
4
4
+
use axum::extract::State;
5
5
+
use axum::http::StatusCode;
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::bookmark::{BookmarkView, BookmarkViewItem};
8
8
-
use lexica::app_bsky::feed::{BlockedAuthor, PostView};
9
9
-
use lexica::StrongRef;
10
10
-
use serde::{Deserialize, Serialize};
11
11
-
12
12
-
#[derive(Debug, Deserialize)]
13
13
-
pub struct CreateBookmarkReq {
14
14
-
pub uri: String,
15
15
-
#[expect(dead_code, reason = "CID required for XRPC API spec but not used in server-side bookmark creation")]
16
16
-
pub cid: String,
17
17
-
}
7
7
+
use jacquard_axum::ExtractXrpc;
8
8
+
use jacquard_api::app_bsky::bookmark::{
9
9
+
BookmarkView, BookmarkViewItem,
10
10
+
create_bookmark::{CreateBookmarkRequest, CreateBookmarkResponse},
11
11
+
delete_bookmark::{DeleteBookmarkRequest, DeleteBookmarkResponse},
12
12
+
get_bookmarks::{GetBookmarksRequest, GetBookmarksOutput},
13
13
+
};
14
14
+
use jacquard_api::com_atproto::repo::strong_ref::StrongRef;
15
15
+
use jacquard_common::IntoStatic;
18
16
19
17
pub async fn create_bookmark(
20
18
State(state): State<GlobalState>,
21
19
auth: AtpAuth,
22
22
-
Json(form): Json<CreateBookmarkReq>,
23
23
-
) -> XrpcResult<()> {
24
24
-
use crate::common::errors::Error;
25
25
-
let mut conn = state.pool.get().await?;
20
20
+
ExtractXrpc(req): ExtractXrpc<CreateBookmarkRequest>,
21
21
+
) -> Result<Json<CreateBookmarkResponse>, StatusCode> {
22
22
+
let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
26
23
27
24
// Resolve auth DID to actor_id using ProfileEntity
28
25
let actor_id = state.profile_entity.resolve_identifier(&auth.0).await
29
29
-
.map_err(|_| Error::actor_not_found(&auth.0))?;
26
26
+
.map_err(|_| StatusCode::NOT_FOUND)?;
30
27
31
28
// Parse AT URI - bookmarks can only be for posts
32
29
// URI format: at://did/app.bsky.feed.post/rkey
33
33
-
let parts = form.uri.strip_prefix("at://")
34
34
-
.ok_or_else(|| Error::invalid_request(Some("Invalid AT URI".to_string())))?
30
30
+
let parts = req.uri.strip_prefix("at://")
31
31
+
.ok_or(StatusCode::BAD_REQUEST)?
35
32
.split('/').collect::<Vec<_>>();
36
33
37
34
if parts.len() != 3 {
38
38
-
return Err(Error::invalid_request(Some("Invalid AT URI format".to_string())));
35
35
+
return Err(StatusCode::BAD_REQUEST);
39
36
}
40
37
41
38
let (post_did, collection, rkey_str) = (parts[0], parts[1], parts[2]);
42
39
43
40
// Validate that this is a post URI
44
41
if collection != "app.bsky.feed.post" {
45
45
-
return Err(Error::invalid_request(Some("Bookmarks can only be created for posts".to_string())));
42
42
+
return Err(StatusCode::BAD_REQUEST);
46
43
}
47
44
48
45
// Resolve post DID to actor_id using ProfileEntity
49
46
let post_actor_id = state.profile_entity.resolve_identifier(post_did).await
50
50
-
.map_err(|_| Error::not_found())?;
47
47
+
.map_err(|_| StatusCode::NOT_FOUND)?;
51
48
52
49
// Decode TID
53
50
let rkey = parakeet_db::tid_util::decode_tid(rkey_str)
54
54
-
.map_err(|_| Error::invalid_request(Some("Invalid TID".to_string())))?;
51
51
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
55
52
56
53
// Update bookmarks array on actor
57
54
use diesel::prelude::*;
···
84
81
.bind::<diesel::sql_types::Integer, _>(post_actor_id)
85
82
.bind::<diesel::sql_types::BigInt, _>(rkey)
86
83
.execute(&mut conn)
87
87
-
.await?;
88
88
-
89
89
-
Ok(())
90
90
-
}
84
84
+
.await
85
85
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
91
86
92
92
-
#[derive(Debug, Deserialize)]
93
93
-
pub struct DeleteBookmarkReq {
94
94
-
pub uri: String,
87
87
+
Ok(Json(CreateBookmarkResponse))
95
88
}
96
89
97
90
pub async fn delete_bookmark(
98
91
State(state): State<GlobalState>,
99
92
auth: AtpAuth,
100
100
-
Json(form): Json<DeleteBookmarkReq>,
101
101
-
) -> XrpcResult<()> {
102
102
-
use crate::common::errors::Error;
103
103
-
let mut conn = state.pool.get().await?;
93
93
+
ExtractXrpc(req): ExtractXrpc<DeleteBookmarkRequest>,
94
94
+
) -> Result<Json<DeleteBookmarkResponse>, StatusCode> {
95
95
+
let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
104
96
105
97
// Resolve auth DID to actor_id using ProfileEntity
106
98
let actor_id = state.profile_entity.resolve_identifier(&auth.0).await
107
107
-
.map_err(|_| Error::actor_not_found(&auth.0))?;
99
99
+
.map_err(|_| StatusCode::NOT_FOUND)?;
108
100
109
101
// Parse AT URI
110
110
-
let parts = form.uri.strip_prefix("at://")
111
111
-
.ok_or_else(|| Error::invalid_request(Some("Invalid AT URI".to_string())))?
102
102
+
let parts = req.uri.strip_prefix("at://")
103
103
+
.ok_or(StatusCode::BAD_REQUEST)?
112
104
.split('/').collect::<Vec<_>>();
113
105
114
106
if parts.len() != 3 {
115
115
-
return Err(Error::invalid_request(Some("Invalid AT URI format".to_string())));
107
107
+
return Err(StatusCode::BAD_REQUEST);
116
108
}
117
109
118
110
let (post_did, collection, rkey_str) = (parts[0], parts[1], parts[2]);
119
111
120
112
// Validate that this is a post URI
121
113
if collection != "app.bsky.feed.post" {
122
122
-
return Err(Error::invalid_request(Some("Bookmarks can only be deleted for posts".to_string())));
114
114
+
return Err(StatusCode::BAD_REQUEST);
123
115
}
124
116
125
117
// Resolve post DID to actor_id using ProfileEntity
126
118
let post_actor_id = state.profile_entity.resolve_identifier(post_did).await
127
127
-
.map_err(|_| Error::not_found())?;
119
119
+
.map_err(|_| StatusCode::NOT_FOUND)?;
128
120
129
121
// Decode TID
130
122
let rkey = parakeet_db::tid_util::decode_tid(rkey_str)
131
131
-
.map_err(|_| Error::invalid_request(Some("Invalid TID".to_string())))?;
123
123
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
132
124
133
125
// Remove bookmark from actor's array
134
126
use diesel::prelude::*;
···
148
140
.bind::<diesel::sql_types::Integer, _>(post_actor_id)
149
141
.bind::<diesel::sql_types::BigInt, _>(rkey)
150
142
.execute(&mut conn)
151
151
-
.await?;
143
143
+
.await
144
144
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
152
145
153
153
-
Ok(())
154
154
-
}
155
155
-
156
156
-
#[derive(Debug, Serialize)]
157
157
-
pub struct GetBookmarksRes {
158
158
-
#[serde(skip_serializing_if = "Option::is_none")]
159
159
-
pub cursor: Option<String>,
160
160
-
pub bookmarks: Vec<BookmarkView>,
146
146
+
Ok(Json(DeleteBookmarkResponse))
161
147
}
162
148
163
149
pub async fn get_bookmarks(
164
150
State(state): State<GlobalState>,
151
151
+
ExtractXrpc(req): ExtractXrpc<GetBookmarksRequest>,
165
152
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
166
153
auth: AtpAuth,
167
167
-
Query(query): Query<CursorQuery>,
168
168
-
) -> XrpcResult<Json<GetBookmarksRes>> {
169
169
-
use crate::common::errors::Error;
170
170
-
let mut conn = state.pool.get().await?;
154
154
+
) -> Result<Json<GetBookmarksOutput<'static>>, StatusCode> {
155
155
+
let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
171
156
172
157
// Resolve auth DID to actor_id using ProfileEntity
173
158
let actor_id = state.profile_entity.resolve_identifier(&auth.0).await
174
174
-
.map_err(|_| Error::actor_not_found(&auth.0))?;
159
159
+
.map_err(|_| StatusCode::NOT_FOUND)?;
175
160
176
176
-
let limit = query.limit.unwrap_or(50).clamp(1, 100);
177
177
-
let cursor_timestamp = datetime_cursor(query.cursor.as_ref());
161
161
+
let limit = req.limit.unwrap_or(50).clamp(1, 100) as u8;
162
162
+
let cursor_str = req.cursor.as_ref().map(|s| s.as_str().to_string());
163
163
+
let cursor_timestamp = datetime_cursor(cursor_str.as_ref());
178
164
179
165
// Get bookmarks from ProfileEntity
180
166
let mut bookmark_data = state.profile_entity.get_bookmarks(
···
182
168
cursor_timestamp.as_ref(),
183
169
limit,
184
170
)
185
185
-
.await?;
171
171
+
.await
172
172
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
186
173
187
174
// Check if there's a next page
188
175
let has_next = bookmark_data.len() > limit as usize;
···
214
201
// Convert CID bytes to string, then parse to Cid
215
202
let cid_str = parakeet_db::cid_util::digest_to_record_cid_string(&cid)
216
203
.unwrap_or_else(|| "bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq".to_string()); // Default CID
217
217
-
let subject = lexica::StrongRef::new_from_str(uri.clone(), &cid_str)
218
218
-
.unwrap_or_else(|_| {
219
219
-
// Fallback if CID parsing fails
220
220
-
lexica::StrongRef::new_from_str(
221
221
-
uri.clone(),
222
222
-
"bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq"
223
223
-
).unwrap()
224
224
-
});
204
204
+
205
205
+
use jacquard_common::IntoStatic;
206
206
+
let subject = jacquard_api::com_atproto::repo::strong_ref::StrongRef {
207
207
+
uri: jacquard_common::types::string::AtUri::new(&uri).unwrap(),
208
208
+
cid: jacquard_common::types::string::Cid::new(cid_str.as_bytes()).unwrap(),
209
209
+
extra_data: None,
210
210
+
}.into_static();
225
211
226
212
// Get post view
227
213
match state.post_entity.get_by_uri(&uri, Some(&auth.0)).await {
228
214
Ok(Some(post_view)) => {
229
215
bookmark_views.push(BookmarkView {
230
216
subject: subject.clone(),
231
231
-
created_at,
232
232
-
item: BookmarkViewItem::Post(Box::new(post_view)),
217
217
+
created_at: Some(jacquard_common::types::datetime::Datetime::from(created_at.fixed_offset())),
218
218
+
item: BookmarkViewItem::PostView(Box::new(post_view)),
219
219
+
extra_data: None,
233
220
});
234
221
}
235
222
_ => {
236
223
// Post not found or error
237
224
bookmark_views.push(BookmarkView {
238
225
subject,
239
239
-
created_at,
240
240
-
item: BookmarkViewItem::NotFound {
241
241
-
uri: uri.clone(),
226
226
+
created_at: Some(jacquard_common::types::datetime::Datetime::from(created_at.fixed_offset())),
227
227
+
item: BookmarkViewItem::NotFoundPost(Box::new(jacquard_api::app_bsky::feed::NotFoundPost {
228
228
+
uri: jacquard_common::types::string::AtUri::new(&uri).unwrap(),
242
229
not_found: true,
243
243
-
},
230
230
+
extra_data: None,
231
231
+
}.into_static())),
232
232
+
extra_data: None,
244
233
});
245
234
}
246
235
}
247
236
}
248
237
249
249
-
Ok(Json(GetBookmarksRes {
250
250
-
cursor,
238
238
+
Ok(Json(GetBookmarksOutput {
239
239
+
cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)),
251
240
bookmarks: bookmark_views,
241
241
+
extra_data: None,
252
242
}))
253
243
}
254
244
255
255
-
#[derive(Debug, Serialize)]
256
256
-
pub struct GetBookmarksCountRes {
257
257
-
pub count: i64,
258
258
-
}
259
259
-
260
260
-
pub async fn get_bookmarks_count(
261
261
-
State(state): State<GlobalState>,
262
262
-
auth: AtpAuth,
263
263
-
) -> XrpcResult<Json<GetBookmarksCountRes>> {
264
264
-
use crate::common::errors::Error;
265
265
-
let mut conn = state.pool.get().await?;
266
266
-
267
267
-
// Resolve auth DID to actor_id using ProfileEntity
268
268
-
let actor_id = state.profile_entity.resolve_identifier(&auth.0).await
269
269
-
.map_err(|_| Error::actor_not_found(&auth.0))?;
270
270
-
271
271
-
// Get bookmark count from ProfileEntity
272
272
-
let count = state.profile_entity.get_bookmarks_count(actor_id).await?;
273
273
-
274
274
-
Ok(Json(GetBookmarksCountRes { count }))
275
275
-
}
245
245
+
// Note: get_bookmarks_count doesn't exist in the Jacquard API,
246
246
+
// this is likely a custom extension that needs to be removed or handled differently
+69
-83
parakeet/src/xrpc/app_bsky/feed/feedgen.rs
···
1
1
-
use crate::common::errors::{Error, XrpcResult};
2
1
use crate::common::auth::{AtpAcceptLabelers, AtpAuth};
3
3
-
use crate::xrpc::{datetime_cursor, ActorWithCursorQuery};
2
2
+
use crate::xrpc::datetime_cursor;
4
3
use crate::GlobalState;
5
5
-
use axum::extract::{Query, State};
4
4
+
use axum::extract::State;
5
5
+
use axum::http::StatusCode;
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::feed::GeneratorView;
8
8
-
use serde::{Deserialize, Serialize};
9
9
-
10
10
-
#[derive(Debug, Serialize)]
11
11
-
pub struct GetActorFeedRes {
12
12
-
#[serde(skip_serializing_if = "Option::is_none")]
13
13
-
cursor: Option<String>,
14
14
-
feeds: Vec<GeneratorView>,
15
15
-
}
7
7
+
use jacquard_axum::ExtractXrpc;
8
8
+
use jacquard_api::app_bsky::feed::{
9
9
+
GeneratorView,
10
10
+
get_actor_feeds::{GetActorFeedsRequest, GetActorFeedsOutput},
11
11
+
get_feed_generator::{GetFeedGeneratorRequest, GetFeedGeneratorOutput},
12
12
+
get_feed_generators::{GetFeedGeneratorsRequest, GetFeedGeneratorsOutput},
13
13
+
get_suggested_feeds::{GetSuggestedFeedsRequest, GetSuggestedFeedsOutput},
14
14
+
};
15
15
+
use jacquard_common::IntoStatic;
16
16
17
17
pub async fn get_actor_feeds(
18
18
State(state): State<GlobalState>,
19
19
+
ExtractXrpc(req): ExtractXrpc<GetActorFeedsRequest>,
19
20
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
20
21
maybe_auth: Option<AtpAuth>,
21
21
-
Query(query): Query<ActorWithCursorQuery>,
22
22
-
) -> XrpcResult<Json<GetActorFeedRes>> {
23
23
-
let mut conn = state.pool.get().await?;
22
22
+
) -> Result<Json<GetActorFeedsOutput<'static>>, StatusCode> {
23
23
+
let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
24
24
25
25
// Get viewer DID if authenticated
26
26
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone());
27
27
28
28
// Resolve actor to actor_id
29
29
-
let actor_id = state.profile_entity.resolve_identifier(&query.actor).await?;
29
29
+
let actor_id = state.profile_entity.resolve_identifier(req.actor.as_ref()).await
30
30
+
.map_err(|_| StatusCode::NOT_FOUND)?;
30
31
31
32
// Check if actor is active
32
33
use diesel::prelude::*;
···
39
40
.filter(actors::status.eq(ActorStatus::Active))
40
41
.select(diesel::dsl::count(actors::id).gt(0))
41
42
.first(&mut conn)
42
42
-
.await?;
43
43
+
.await
44
44
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
43
45
44
46
if !is_active {
45
45
-
return Err(Error::actor_not_found(&query.actor));
47
47
+
return Err(StatusCode::NOT_FOUND);
46
48
}
47
49
48
48
-
let limit = query.limit.unwrap_or(50).clamp(1, 100);
49
49
-
let cursor_timestamp = datetime_cursor(query.cursor.as_ref());
50
50
+
let limit = req.limit.unwrap_or(50).clamp(1, 100);
51
51
+
let cursor_timestamp = req.cursor.as_ref()
52
52
+
.map(|c| c.as_ref())
53
53
+
.and_then(|c| datetime_cursor(Some(&c.to_string())));
50
54
51
55
// Get feedgens owned by this actor using FeedGeneratorEntity
52
56
let results = state.feedgen_entity.get_actor_feedgens(
···
55
59
limit,
56
60
)
57
61
.await
58
58
-
.map_err(|e| Error::server_error(Some(&e.to_string())))?;
62
62
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
59
63
60
64
let cursor = results
61
65
.last()
···
63
67
64
68
// Get DIDs for all actor_ids involved
65
69
let actor_ids: Vec<i32> = results.iter().map(|r| r.1).collect();
66
66
-
let actors = state.profile_entity.get_profiles_by_ids(&actor_ids).await?;
70
70
+
let actors = state.profile_entity.get_profiles_by_ids(&actor_ids).await
71
71
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
67
72
68
73
let mut actor_id_to_did = std::collections::HashMap::new();
69
74
for actor in actors {
···
82
87
// Get feed generators using entity
83
88
let mut feeds_map = state.feedgen_entity
84
89
.get_by_uris(at_uris.clone(), viewer_did.as_deref())
85
85
-
.await?;
90
90
+
.await
91
91
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
86
92
87
93
// Preserve original order
88
94
let feeds = at_uris
···
90
96
.filter_map(|uri| feeds_map.remove(&uri))
91
97
.collect();
92
98
93
93
-
Ok(Json(GetActorFeedRes { cursor, feeds }))
94
94
-
}
95
95
-
96
96
-
#[derive(Debug, Deserialize)]
97
97
-
pub struct GetFeedGeneratorQuery {
98
98
-
pub feed: String,
99
99
-
}
100
100
-
101
101
-
#[derive(Debug, Serialize)]
102
102
-
#[serde(rename_all = "camelCase")]
103
103
-
pub struct GetFeedGeneratorRes {
104
104
-
pub view: GeneratorView,
105
105
-
pub is_online: bool,
106
106
-
pub is_valid: bool,
99
99
+
Ok(Json(GetActorFeedsOutput {
100
100
+
cursor: cursor.map(|c| jacquard_common::CowStr::from(c)),
101
101
+
feeds,
102
102
+
extra_data: None,
103
103
+
}.into_static()))
107
104
}
108
105
109
106
pub async fn get_feed_generator(
110
107
State(state): State<GlobalState>,
108
108
+
ExtractXrpc(req): ExtractXrpc<GetFeedGeneratorRequest>,
111
109
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
112
110
maybe_auth: Option<AtpAuth>,
113
113
-
Query(query): Query<GetFeedGeneratorQuery>,
114
114
-
) -> XrpcResult<Json<GetFeedGeneratorRes>> {
111
111
+
) -> Result<Json<GetFeedGeneratorOutput<'static>>, StatusCode> {
115
112
// Get viewer DID if authenticated
116
113
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone());
117
114
118
115
// Get the feed generator using entity
119
116
let view = state.feedgen_entity
120
120
-
.get_by_uri(&query.feed, viewer_did.as_deref())
121
121
-
.await?
122
122
-
.ok_or_else(|| Error::not_found())?;
117
117
+
.get_by_uri(&req.feed, viewer_did.as_deref())
118
118
+
.await
119
119
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
120
120
+
.ok_or(StatusCode::NOT_FOUND)?;
123
121
124
122
// For now, assume all feeds are online and valid
125
123
// In production, you'd check the service status
126
126
-
Ok(Json(GetFeedGeneratorRes {
127
127
-
view,
124
124
+
Ok(Json(GetFeedGeneratorOutput {
125
125
+
view: view.into_static(),
128
126
is_online: true,
129
127
is_valid: true,
128
128
+
extra_data: None,
130
129
}))
131
130
}
132
131
133
133
-
#[derive(Debug, Deserialize)]
134
134
-
pub struct GetFeedGeneratorsQuery {
135
135
-
pub feeds: Vec<String>,
136
136
-
}
137
137
-
138
138
-
#[derive(Debug, Serialize)]
139
139
-
pub struct GetFeedGeneratorsRes {
140
140
-
pub feeds: Vec<GeneratorView>,
141
141
-
}
142
142
-
143
132
pub async fn get_feed_generators(
144
133
State(state): State<GlobalState>,
134
134
+
ExtractXrpc(req): ExtractXrpc<GetFeedGeneratorsRequest>,
145
135
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
146
136
maybe_auth: Option<AtpAuth>,
147
147
-
axum_extra::extract::Query(query): axum_extra::extract::Query<GetFeedGeneratorsQuery>,
148
148
-
) -> XrpcResult<Json<GetFeedGeneratorsRes>> {
137
137
+
) -> Result<Json<GetFeedGeneratorsOutput<'static>>, StatusCode> {
149
138
// Get viewer DID if authenticated
150
139
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone());
151
140
152
141
// Get feed generators using entity
153
142
let feeds_map = state.feedgen_entity
154
154
-
.get_by_uris(query.feeds.clone(), viewer_did.as_deref())
155
155
-
.await?;
143
143
+
.get_by_uris(req.feeds.iter().map(|f| f.to_string()).collect(), viewer_did.as_deref())
144
144
+
.await
145
145
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
156
146
157
147
// Preserve original order
158
158
-
let feeds = query.feeds
148
148
+
let feeds = req.feeds
159
149
.into_iter()
160
160
-
.filter_map(|uri| feeds_map.get(&uri).cloned())
150
150
+
.filter_map(|uri| feeds_map.get(&uri.to_string()).cloned())
161
151
.collect();
162
152
163
163
-
Ok(Json(GetFeedGeneratorsRes { feeds }))
164
164
-
}
165
165
-
166
166
-
#[derive(Debug, Deserialize)]
167
167
-
pub struct GetSuggestedFeedsQuery {
168
168
-
pub limit: Option<u16>,
169
169
-
pub cursor: Option<String>,
170
170
-
}
171
171
-
172
172
-
#[derive(Debug, Serialize)]
173
173
-
pub struct GetSuggestedFeedsRes {
174
174
-
#[serde(skip_serializing_if = "Option::is_none")]
175
175
-
cursor: Option<String>,
176
176
-
feeds: Vec<GeneratorView>,
153
153
+
Ok(Json(GetFeedGeneratorsOutput {
154
154
+
feeds,
155
155
+
extra_data: None,
156
156
+
}.into_static()))
177
157
}
178
158
179
159
/// Get suggested feeds ranked by popularity
180
160
pub async fn get_suggested_feeds(
181
161
State(state): State<GlobalState>,
162
162
+
ExtractXrpc(req): ExtractXrpc<GetSuggestedFeedsRequest>,
182
163
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
183
164
maybe_auth: Option<AtpAuth>,
184
184
-
Query(query): Query<GetSuggestedFeedsQuery>,
185
185
-
) -> XrpcResult<Json<GetSuggestedFeedsRes>> {
186
186
-
let limit = query.limit.unwrap_or(50).clamp(1, 100) as usize;
187
187
-
let offset = query
165
165
+
) -> Result<Json<GetSuggestedFeedsOutput<'static>>, StatusCode> {
166
166
+
let limit = req.limit.unwrap_or(50).clamp(1, 100) as usize;
167
167
+
let offset = req
188
168
.cursor
189
169
.as_ref()
190
170
.and_then(|c| c.parse::<usize>().ok())
···
193
173
// Fetch all feedgens ordered by like count using FeedGeneratorEntity
194
174
let feedgens_ranked = state.feedgen_entity.get_all_feedgens_by_likes()
195
175
.await
196
196
-
.map_err(|e| Error::server_error(Some(&e.to_string())))?;
176
176
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
197
177
198
178
if feedgens_ranked.is_empty() {
199
199
-
return Ok(Json(GetSuggestedFeedsRes {
179
179
+
return Ok(Json(GetSuggestedFeedsOutput {
200
180
cursor: None,
201
181
feeds: Vec::new(),
202
202
-
}));
182
182
+
extra_data: None,
183
183
+
}.into_static()));
203
184
}
204
185
205
186
// Apply pagination
···
241
222
// Get feed generators using entity
242
223
let feeds_map = state.feedgen_entity
243
224
.get_by_uris(page_uris.clone(), viewer_did.as_deref())
244
244
-
.await?;
225
225
+
.await
226
226
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
245
227
246
228
// Preserve original order
247
229
let feeds: Vec<GeneratorView> = page_uris
···
249
231
.filter_map(|uri| feeds_map.get(&uri).cloned())
250
232
.collect();
251
233
252
252
-
Ok(Json(GetSuggestedFeedsRes { cursor, feeds }))
234
234
+
Ok(Json(GetSuggestedFeedsOutput {
235
235
+
cursor: cursor.map(|c| jacquard_common::CowStr::from(c)),
236
236
+
feeds,
237
237
+
extra_data: None,
238
238
+
}.into_static()))
253
239
}
+50
-60
parakeet/src/xrpc/app_bsky/feed/get_timeline.rs
···
1
1
use crate::xrpc::datetime_cursor;
2
2
-
use crate::common::errors::XrpcResult;
3
2
use crate::common::auth::{AtpAcceptLabelers, AtpAuth};
4
3
use crate::GlobalState;
5
4
use axum::extract::{Query, State};
5
5
+
use axum::http::StatusCode;
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::feed::{FeedViewPost, PostView};
7
7
+
use jacquard_axum::ExtractXrpc;
8
8
+
use jacquard_api::app_bsky::feed::{
9
9
+
FeedViewPost,
10
10
+
get_timeline::{GetTimelineRequest, GetTimelineOutput},
11
11
+
get_author_feed::{GetAuthorFeedRequest, GetAuthorFeedOutput},
12
12
+
};
13
13
+
use jacquard_common::IntoStatic;
8
14
use serde::{Deserialize, Serialize};
9
15
10
10
-
#[derive(Debug, Deserialize)]
11
11
-
pub struct GetTimelineQuery {
12
12
-
#[expect(dead_code)]
13
13
-
pub algorithm: Option<String>,
14
14
-
pub limit: Option<u8>,
15
15
-
pub cursor: Option<String>,
16
16
-
}
17
17
-
18
18
-
#[derive(Debug, Serialize)]
19
19
-
pub struct GetTimelineRes {
20
20
-
#[serde(skip_serializing_if = "Option::is_none")]
21
21
-
cursor: Option<String>,
22
22
-
feed: Vec<FeedViewPost>,
23
23
-
}
24
24
-
25
16
pub async fn get_timeline(
26
17
State(state): State<GlobalState>,
18
18
+
ExtractXrpc(req): ExtractXrpc<GetTimelineRequest>,
27
19
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
28
20
auth: AtpAuth,
29
29
-
Query(query): Query<GetTimelineQuery>,
30
30
-
) -> XrpcResult<Json<GetTimelineRes>> {
21
21
+
) -> Result<Json<GetTimelineOutput<'static>>, StatusCode> {
31
22
let start = std::time::Instant::now();
32
23
33
24
// Get the user's DID from auth
34
25
let user_did = auth.0.clone();
35
26
36
27
// Set the limit, clamped to reasonable values
37
37
-
let limit = query.limit.unwrap_or(50).clamp(1, 100);
28
28
+
let limit = req.limit.unwrap_or(50).clamp(1, 100);
38
29
39
39
-
tracing::info!("getTimeline request: limit={}, cursor={:?}", limit, query.cursor);
30
30
+
tracing::info!("getTimeline request: limit={}, cursor={:?}", limit, req.cursor);
40
31
41
32
// Resolve actor_id using ProfileEntity
42
33
let user_actor_id = state.profile_entity.resolve_identifier(&user_did).await
43
43
-
.map_err(|_| crate::common::errors::Error::actor_not_found(&user_did))?;
34
34
+
.map_err(|_| StatusCode::NOT_FOUND)?;
44
35
45
36
// New pattern: Get profiles with their posts, then hydrate
46
37
let mut step_timer = std::time::Instant::now();
47
38
48
39
// Parse cursor (convert to TID if provided)
49
49
-
let cursor_tid = query.cursor.as_ref()
50
50
-
.and_then(|c| datetime_cursor(Some(c)))
40
40
+
let cursor_tid = req.cursor.as_ref()
41
41
+
.map(|c| c.as_ref())
42
42
+
.and_then(|c| datetime_cursor(Some(&c.to_string())))
51
43
.map(|dt| {
52
44
let tid_str = parakeet_db::tid_util::timestamp_to_tid(dt);
53
45
parakeet_db::tid_util::decode_tid(&tid_str).unwrap_or(0)
···
152
144
reply: reply_context,
153
145
reason: None,
154
146
feed_context: None,
147
147
+
extra_data: None,
148
148
+
req_id: None,
155
149
});
156
150
}
157
151
}
···
173
167
let reposter_basic = state.profile_entity.actor_to_profile_view_basic(&reposter, Some(&user_did)).await;
174
168
175
169
// Build the repost reason
176
176
-
let reason = Some(lexica::app_bsky::feed::FeedViewPostReason::Repost(Box::new(
177
177
-
lexica::app_bsky::feed::FeedReasonRepost {
170
170
+
let reason = Some(jacquard_api::app_bsky::feed::FeedViewPostReason::ReasonRepost(Box::new(
171
171
+
jacquard_api::app_bsky::feed::ReasonRepost {
178
172
by: reposter_basic,
179
179
-
uri: Some(format!(
173
173
+
uri: Some(jacquard_common::types::aturi::AtUri::new(&format!(
180
174
"at://{}/app.bsky.feed.repost/{}",
181
175
reposter.did,
182
176
parakeet_db::tid_util::encode_tid(rkey)
183
183
-
)),
177
177
+
)).unwrap()),
184
178
cid: None, // TODO: Add CID if needed
185
185
-
indexed_at,
179
179
+
indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()),
180
180
+
extra_data: None,
186
181
}
187
182
)));
188
183
···
194
189
reply: reply_context,
195
190
reason,
196
191
feed_context: None,
192
192
+
extra_data: None,
193
193
+
req_id: None,
197
194
});
198
195
}
199
196
}
···
209
206
let total_time = start.elapsed().as_secs_f64() * 1000.0;
210
207
tracing::info!(" └─ getTimeline total: {:.1} ms (returning {} posts, cursor: {:?})", total_time, feed.len(), cursor);
211
208
212
212
-
Ok(Json(GetTimelineRes {
213
213
-
cursor,
209
209
+
Ok(Json(GetTimelineOutput {
210
210
+
cursor: cursor.map(|c| jacquard_common::CowStr::from(c)),
214
211
feed,
215
215
-
}))
212
212
+
extra_data: None,
213
213
+
}.into_static()))
216
214
}
217
215
218
216
pub async fn get_author_feed(
219
217
State(state): State<GlobalState>,
218
218
+
ExtractXrpc(req): ExtractXrpc<GetAuthorFeedRequest>,
220
219
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
221
220
maybe_auth: Option<AtpAuth>,
222
222
-
Query(query): Query<GetAuthorFeedQuery>,
223
223
-
) -> XrpcResult<Json<GetAuthorFeedRes>> {
221
221
+
) -> Result<Json<GetAuthorFeedOutput<'static>>, StatusCode> {
224
222
let start = std::time::Instant::now();
225
223
226
224
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone());
227
227
-
let limit = query.limit.unwrap_or(50).clamp(1, 100);
225
225
+
let limit = req.limit.unwrap_or(50).clamp(1, 100);
228
226
229
227
tracing::info!("getAuthorFeed: actor={}, filter={:?}, limit={}, cursor={:?}",
230
230
-
query.actor, query.filter, limit, query.cursor);
228
228
+
req.actor, req.filter, limit, req.cursor);
231
229
232
230
// Resolve actor to actor_id using ProfileEntity (only DID resolution here)
233
233
-
let actor_id = state.profile_entity.resolve_identifier(&query.actor).await
234
234
-
.map_err(|_| crate::common::errors::Error::actor_not_found(&query.actor))?;
231
231
+
let actor_id = state.profile_entity.resolve_identifier(req.actor.as_ref()).await
232
232
+
.map_err(|_| StatusCode::NOT_FOUND)?;
235
233
236
234
// Parse cursor (convert to TID if provided)
237
237
-
let cursor_tid = query.cursor.as_ref()
238
238
-
.and_then(|c| datetime_cursor(Some(c)))
235
235
+
let cursor_tid = req.cursor.as_ref()
236
236
+
.map(|c| c.as_ref())
237
237
+
.and_then(|c| datetime_cursor(Some(&c.to_string())))
239
238
.map(|dt| {
240
239
let tid_str = parakeet_db::tid_util::timestamp_to_tid(dt);
241
240
parakeet_db::tid_util::decode_tid(&tid_str).unwrap_or(0)
···
245
244
let mut step_timer = std::time::Instant::now();
246
245
247
246
let post_keys = state.profile_entity
248
248
-
.get_author_posts(actor_id, limit as usize + 1, cursor_tid, query.filter.as_deref())
249
249
-
.await?;
247
247
+
.get_author_posts(actor_id, limit as usize + 1, cursor_tid, req.filter.as_ref().map(|f| f.as_ref()))
248
248
+
.await
249
249
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
250
250
251
251
let profile_time = step_timer.elapsed().as_secs_f64() * 1000.0;
252
252
tracing::info!(" ├─ Get author posts: {:.1} ms ({} posts)", profile_time, post_keys.len());
···
275
275
.unwrap_or_default();
276
276
277
277
// Get reply context if needed
278
278
-
let mut posts_with_reply: Vec<(PostView, Option<lexica::app_bsky::feed::ReplyRef>)> = Vec::new();
278
278
+
let mut posts_with_reply: Vec<(jacquard_api::app_bsky::feed::PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)> = Vec::new();
279
279
280
280
for post_data in post_data_list {
281
281
// Convert to PostView
···
300
300
reply: reply_context,
301
301
reason: None,
302
302
feed_context: None,
303
303
+
extra_data: None,
304
304
+
req_id: None,
303
305
});
304
306
}
305
307
306
308
let total_time = start.elapsed().as_secs_f64() * 1000.0;
307
309
tracing::info!(" └─ getAuthorFeed total: {:.1} ms (returning {} posts)", total_time, feed.len());
308
310
309
309
-
Ok(Json(GetAuthorFeedRes {
310
310
-
cursor,
311
311
+
Ok(Json(GetAuthorFeedOutput {
312
312
+
cursor: cursor.map(|c| jacquard_common::CowStr::from(c)),
311
313
feed,
312
312
-
}))
314
314
+
extra_data: None,
315
315
+
}.into_static()))
313
316
}
314
317
315
315
-
#[derive(Debug, Deserialize)]
316
316
-
pub struct GetAuthorFeedQuery {
317
317
-
pub actor: String,
318
318
-
pub limit: Option<u8>,
319
319
-
pub cursor: Option<String>,
320
320
-
pub filter: Option<String>,
321
321
-
}
322
322
-
323
323
-
#[derive(Debug, Serialize)]
324
324
-
pub struct GetAuthorFeedRes {
325
325
-
#[serde(skip_serializing_if = "Option::is_none")]
326
326
-
pub cursor: Option<String>,
327
327
-
pub feed: Vec<FeedViewPost>,
328
328
-
}
318
318
+
// GetAuthorFeedQuery and GetAuthorFeedRes are replaced by jacquard types
+10
-6
parakeet/src/xrpc/app_bsky/feed/likes.rs
···
4
4
use crate::GlobalState;
5
5
use axum::extract::{Query, State};
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::feed::{FeedViewPost, Like};
8
8
-
use lexica::app_bsky::actor::ProfileView;
7
7
+
use jacquard_api::app_bsky::feed::FeedViewPost;
8
8
+
use jacquard_api::app_bsky::feed::get_likes::Like;
9
9
+
use jacquard_api::app_bsky::actor::ProfileView;
9
10
use serde::{Deserialize, Serialize};
10
11
11
12
#[derive(Debug, Serialize)]
12
13
pub struct FeedRes {
13
14
#[serde(skip_serializing_if = "Option::is_none")]
14
15
cursor: Option<String>,
15
15
-
feed: Vec<FeedViewPost>,
16
16
+
feed: Vec<FeedViewPost<'static>>,
16
17
}
17
18
18
19
pub async fn get_actor_likes(
···
73
74
reply: None,
74
75
reason: None,
75
76
feed_context: None,
77
77
+
extra_data: None,
78
78
+
req_id: None,
76
79
});
77
80
}
78
81
}
···
93
96
pub uri: String,
94
97
#[serde(skip_serializing_if = "Option::is_none")]
95
98
pub cid: Option<String>,
96
96
-
pub likes: Vec<Like>,
99
99
+
pub likes: Vec<Like<'static>>,
97
100
#[serde(skip_serializing_if = "Option::is_none")]
98
101
pub cursor: Option<String>,
99
102
}
···
146
149
let actor_view = state.profile_entity.actor_to_profile_view(&liker);
147
150
148
151
likes.push(Like {
149
149
-
indexed_at,
150
150
-
created_at: indexed_at,
151
152
actor: actor_view,
153
153
+
created_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()),
154
154
+
indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()),
155
155
+
extra_data: None,
152
156
});
153
157
}
154
158
}
+4
-4
parakeet/src/xrpc/app_bsky/feed/posts/feeds.rs
···
1
1
use axum::extract::{Query, State};
2
2
use axum::Json;
3
3
-
use lexica::app_bsky::feed::{FeedViewPost, GeneratorView};
3
3
+
use jacquard_api::app_bsky::feed::{FeedViewPost, GeneratorView};
4
4
use serde::{Deserialize, Serialize};
5
5
6
6
use crate::xrpc::datetime_cursor;
···
19
19
pub struct GetFeedRes {
20
20
#[serde(skip_serializing_if = "Option::is_none")]
21
21
pub cursor: Option<String>,
22
22
-
pub feed: Vec<FeedViewPost>,
22
22
+
pub feed: Vec<FeedViewPost<'static>>,
23
23
}
24
24
25
25
pub async fn get_feed(
···
67
67
68
68
#[derive(Debug, Serialize)]
69
69
pub struct GetFeedGeneratorRes {
70
70
-
pub view: GeneratorView,
70
70
+
pub view: GeneratorView<'static>,
71
71
pub is_online: bool,
72
72
pub is_valid: bool,
73
73
}
···
100
100
101
101
#[derive(Debug, Serialize)]
102
102
pub struct GetFeedGeneratorsRes {
103
103
-
pub feeds: Vec<GeneratorView>,
103
103
+
pub feeds: Vec<GeneratorView<'static>>,
104
104
}
105
105
106
106
pub async fn get_feed_generators(
+18
-14
parakeet/src/xrpc/app_bsky/feed/posts/helpers.rs
···
3
3
use axum_extra::TypedHeader;
4
4
use chrono::NaiveDateTime;
5
5
use diesel_async::AsyncPgConnection;
6
6
-
use lexica::app_bsky::feed::{
7
7
-
BlockedAuthor, FeedSkeletonResponse, PostView, ThreadViewPost, ThreadViewPostType,
6
6
+
use jacquard_api::app_bsky::feed::{
7
7
+
BlockedAuthor, PostView, ThreadViewPost,
8
8
+
get_post_thread::GetPostThreadOutputThread as ThreadViewPostType,
9
9
+
get_feed_skeleton::GetFeedSkeletonOutput as FeedSkeletonResponse,
8
10
};
9
11
use reqwest::Url;
10
12
use std::collections::HashMap;
···
14
16
#[expect(dead_code)]
15
17
const FEEDGEN_SERVICE_ID: &str = "#bsky_fg";
16
18
17
17
-
pub(super) async fn get_feed_skeleton(
19
19
+
pub(super) async fn get_feed_skeleton<'a>(
18
20
client: &reqwest::Client,
19
21
feed: &str,
20
22
service: &str,
21
23
maybe_tok: Option<&TypedHeader<Authorization<Bearer>>>,
22
24
limit: Option<u8>,
23
25
cursor: Option<String>,
24
24
-
) -> XrpcResult<FeedSkeletonResponse> {
26
26
+
) -> XrpcResult<FeedSkeletonResponse<'a>> {
25
27
let mut params = vec![("feed", feed.to_owned())];
26
28
27
29
if let Some(cursor) = cursor {
···
65
67
HashMap::new()
66
68
}
67
69
68
68
-
pub(super) fn postview_to_tvpt(
69
69
-
post: PostView,
70
70
-
parent: Option<ThreadViewPostType>,
71
71
-
replies: Vec<ThreadViewPostType>,
72
72
-
) -> ThreadViewPostType {
70
70
+
pub(super) fn postview_to_tvpt<'a>(
71
71
+
post: PostView<'a>,
72
72
+
parent: Option<ThreadViewPostType<'a>>,
73
73
+
replies: Vec<ThreadViewPostType<'a>>,
74
74
+
) -> ThreadViewPostType<'a> {
73
75
match &post.author.viewer {
74
74
-
Some(v) if v.blocked_by || v.blocking.is_some() => ThreadViewPostType::Blocked {
76
76
+
Some(v) if v.blocked_by.unwrap_or(false) || v.blocking.is_some() => ThreadViewPostType::BlockedPost(Box::new(jacquard_api::app_bsky::feed::BlockedPost {
75
77
uri: post.uri.clone(),
76
78
blocked: true,
77
77
-
author: Box::new(BlockedAuthor {
79
79
+
author: BlockedAuthor {
78
80
did: post.author.did,
79
81
viewer: post.author.viewer,
80
80
-
}),
81
81
-
},
82
82
+
extra_data: None,
83
83
+
},
84
84
+
extra_data: None,
85
85
+
})),
82
86
_ => ThreadViewPostType::Post(Box::new(ThreadViewPost {
83
87
post,
84
88
parent,
85
85
-
replies,
89
89
+
replies: Some(replies.into_iter().map(|r| jacquard_api::app_bsky::feed::ThreadViewPostRepliesItem::ThreadViewPost(Box::new(r))).collect()),
86
90
})),
87
91
}
88
92
}
+5
-5
parakeet/src/xrpc/app_bsky/feed/posts/queries.rs
···
1
1
use axum::extract::{Query, State};
2
2
use axum::Json;
3
3
use axum_extra::extract::Query as ExtraQuery;
4
4
-
use lexica::app_bsky::feed::PostView;
4
4
+
use jacquard_api::app_bsky::feed::PostView;
5
5
use serde::{Deserialize, Serialize};
6
6
7
7
use crate::common::errors::XrpcResult;
···
15
15
16
16
#[derive(Debug, Serialize)]
17
17
pub struct PostsRes {
18
18
-
pub posts: Vec<PostView>,
18
18
+
pub posts: Vec<PostView<'static>>,
19
19
}
20
20
21
21
pub async fn get_posts(
···
34
34
let posts_map = state.post_entity.get_by_uris(uris.clone(), viewer_did.as_deref()).await?;
35
35
36
36
// Preserve the order of the original URIs
37
37
-
let posts: Vec<PostView> = uris
37
37
+
let posts: Vec<PostView<'static>> = uris
38
38
.into_iter()
39
39
.filter_map(|uri| posts_map.get(&uri).cloned())
40
40
.collect();
···
99
99
pub cid: Option<String>,
100
100
#[serde(skip_serializing_if = "Option::is_none")]
101
101
pub cursor: Option<String>,
102
102
-
pub posts: Vec<PostView>,
102
102
+
pub posts: Vec<PostView<'static>>,
103
103
}
104
104
105
105
pub async fn get_quotes(
···
203
203
pub cid: Option<String>,
204
204
#[serde(skip_serializing_if = "Option::is_none")]
205
205
pub cursor: Option<String>,
206
206
-
pub reposted_by: Vec<lexica::app_bsky::actor::ProfileView>,
206
206
+
pub reposted_by: Vec<jacquard_api::app_bsky::actor::ProfileView<'static>>,
207
207
}
208
208
209
209
pub async fn get_reposted_by(
+48
-22
parakeet/src/xrpc/app_bsky/feed/posts/threads.rs
···
1
1
use axum::extract::{Query, State};
2
2
use axum::Json;
3
3
-
use lexica::app_bsky::feed::{BlockedAuthor, PostView, ThreadViewPost, ThreadViewPostType, ThreadgateView};
3
3
+
use jacquard_api::app_bsky::feed::{
4
4
+
BlockedAuthor, PostView, ThreadViewPost, ThreadgateView,
5
5
+
get_post_thread::GetPostThreadOutputThread as ThreadViewPostType
6
6
+
};
4
7
use serde::{Deserialize, Serialize};
5
8
use std::collections::HashMap;
6
9
···
20
23
}
21
24
22
25
#[derive(Debug, Serialize)]
23
23
-
pub struct GetPostThreadRes {
24
24
-
pub thread: ThreadViewPostType,
26
26
+
pub struct GetPostThreadRes<'a> {
27
27
+
pub thread: ThreadViewPostType<'a>,
25
28
#[serde(skip_serializing_if = "Option::is_none")]
26
26
-
pub threadgate: Option<ThreadgateView>,
29
29
+
pub threadgate: Option<ThreadgateView<'static>>,
27
30
}
28
31
29
32
pub async fn get_post_thread(
···
31
34
AtpAcceptLabelers(_labelers): AtpAcceptLabelers,
32
35
maybe_auth: Option<AtpAuth>,
33
36
Query(query): Query<GetPostThreadQuery>,
34
34
-
) -> XrpcResult<Json<GetPostThreadRes>> {
37
37
+
) -> XrpcResult<Json<GetPostThreadRes<'static>>> {
35
38
let mut conn = state.pool.get().await?;
36
39
let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone());
37
40
···
47
50
48
51
// Check if author is blocked
49
52
if let Some(viewer) = &root.author.viewer {
50
50
-
if viewer.blocked_by || viewer.blocking.is_some() {
53
53
+
if viewer.blocked_by.unwrap_or(false) || viewer.blocking.is_some() {
51
54
return Ok(Json(GetPostThreadRes {
52
52
-
thread: ThreadViewPostType::Blocked {
53
53
-
uri,
55
55
+
thread: ThreadViewPostType::BlockedPost(Box::new(jacquard_api::app_bsky::feed::BlockedPost {
56
56
+
uri: jacquard_common::types::aturi::AtUri::new(&uri).unwrap(),
54
57
blocked: true,
55
55
-
author: Box::new(BlockedAuthor {
58
58
+
author: BlockedAuthor {
56
59
did: root.author.did.clone(),
57
60
viewer: root.author.viewer.clone(),
58
58
-
}),
59
59
-
},
61
61
+
extra_data: None,
62
62
+
},
63
63
+
extra_data: None,
64
64
+
})),
60
65
threadgate,
61
66
}));
62
67
}
···
78
83
.map_err(|_| Error::invalid_request(Some("Invalid rkey".to_string())))?;
79
84
80
85
// Get root info for parent query filtering
81
81
-
let root_uri = root.record.get("reply")
82
82
-
.and_then(|reply| reply.get("root"))
83
83
-
.and_then(|root| root.get("uri"))
84
84
-
.and_then(|uri| uri.as_str())
85
85
-
.map(|s| s.to_string());
86
86
+
let root_uri = if let jacquard_common::Data::Object(ref obj) = root.record {
87
87
+
obj.get("reply")
88
88
+
.and_then(|reply| {
89
89
+
if let jacquard_common::Data::Object(ref reply_obj) = reply {
90
90
+
reply_obj.get("root")
91
91
+
} else {
92
92
+
None
93
93
+
}
94
94
+
})
95
95
+
.and_then(|root| {
96
96
+
if let jacquard_common::Data::Object(ref root_obj) = root {
97
97
+
root_obj.get("uri")
98
98
+
} else {
99
99
+
None
100
100
+
}
101
101
+
})
102
102
+
.and_then(|uri| {
103
103
+
if let jacquard_common::Data::String(ref s) = uri {
104
104
+
Some(s.to_string())
105
105
+
} else {
106
106
+
None
107
107
+
}
108
108
+
})
109
109
+
} else {
110
110
+
None
111
111
+
};
86
112
87
113
let root_info = if let Some(ref root_uri_str) = root_uri {
88
88
-
let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect();
114
114
+
let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect::<Vec<_>>();
89
115
if root_parts.len() >= 3 {
90
116
let root_did = root_parts[0];
91
117
let root_rkey_base32 = root_parts[2];
···
151
177
}))
152
178
}
153
179
154
154
-
fn build_thread_structure(
155
155
-
root: PostView,
180
180
+
fn build_thread_structure<'a>(
181
181
+
root: PostView<'a>,
156
182
parents: Vec<ThreadItem>,
157
183
children: Vec<ThreadItem>,
158
158
-
posts_map: HashMap<String, PostView>,
159
159
-
) -> ThreadViewPostType {
184
184
+
posts_map: HashMap<String, PostView<'a>>,
185
185
+
) -> ThreadViewPostType<'a> {
160
186
// Convert root to ThreadViewPost
161
187
let root_tvp = ThreadViewPost {
162
188
post: root,
163
189
parent: None,
164
164
-
replies: Vec::new(), // Will be populated
190
190
+
replies: Some(Vec::new()), // Will be populated
165
191
};
166
192
167
193
// TODO: Build full thread structure with parents and children
+2
-2
parakeet/src/xrpc/app_bsky/feed/search.rs
···
3
3
use crate::GlobalState;
4
4
use axum::extract::{Query, State};
5
5
use axum::Json;
6
6
-
use lexica::app_bsky::feed::PostView;
6
6
+
use jacquard_api::app_bsky::feed::PostView;
7
7
use serde::{Deserialize, Serialize};
8
8
9
9
#[derive(Debug, Deserialize)]
···
24
24
25
25
#[derive(Debug, Serialize)]
26
26
pub struct SearchPostsRes {
27
27
-
pub posts: Vec<PostView>,
27
27
+
pub posts: Vec<PostView<'static>>,
28
28
#[serde(skip_serializing_if = "Option::is_none")]
29
29
pub cursor: Option<String>,
30
30
pub hits_total: Option<i64>,
+5
-4
parakeet/src/xrpc/app_bsky/graph/lists.rs
···
4
4
use crate::GlobalState;
5
5
use axum::extract::{Query, State};
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::graph::{ListItemView, ListView};
7
7
+
use jacquard_api::app_bsky::graph::{ListItemView, ListView};
8
8
use serde::{Deserialize, Serialize};
9
9
10
10
#[derive(Debug, Deserialize)]
···
18
18
pub struct GetListsRes {
19
19
#[serde(skip_serializing_if = "Option::is_none")]
20
20
cursor: Option<String>,
21
21
-
lists: Vec<ListView>,
21
21
+
lists: Vec<ListView<'static>>,
22
22
}
23
23
24
24
pub async fn get_lists(
···
75
75
pub struct AppBskyGraphGetListRes {
76
76
#[serde(skip_serializing_if = "Option::is_none")]
77
77
cursor: Option<String>,
78
78
-
list: ListView,
79
79
-
items: Vec<ListItemView>,
78
78
+
list: ListView<'static>,
79
79
+
items: Vec<ListItemView<'static>>,
80
80
}
81
81
82
82
#[derive(Debug, Deserialize)]
···
178
178
Some(ListItemView {
179
179
uri: item_uri,
180
180
subject,
181
181
+
extra_data: None,
181
182
})
182
183
})
183
184
.collect();
+3
-3
parakeet/src/xrpc/app_bsky/graph/mutes.rs
···
5
5
use crate::GlobalState;
6
6
use axum::extract::{Query, State};
7
7
use axum::Json;
8
8
-
use lexica::app_bsky::actor::ProfileView;
8
8
+
use jacquard_api::app_bsky::actor::ProfileView;
9
9
use serde::Serialize;
10
10
11
11
#[derive(Debug, Serialize)]
12
12
pub struct MutesRes {
13
13
#[serde(skip_serializing_if = "Option::is_none")]
14
14
pub cursor: Option<String>,
15
15
-
pub mutes: Vec<ProfileView>,
15
15
+
pub mutes: Vec<ProfileView<'static>>,
16
16
}
17
17
18
18
pub async fn get_mutes(
···
93
93
pub struct MuteListsRes {
94
94
#[serde(skip_serializing_if = "Option::is_none")]
95
95
pub cursor: Option<String>,
96
96
-
pub lists: Vec<lexica::app_bsky::graph::ListView>,
96
96
+
pub lists: Vec<jacquard_api::app_bsky::graph::ListView<'static>>,
97
97
}
98
98
99
99
pub async fn get_muted_words(
+7
-7
parakeet/src/xrpc/app_bsky/graph/relations.rs
···
47
47
pub struct BlocksRes {
48
48
#[serde(skip_serializing_if = "Option::is_none")]
49
49
pub cursor: Option<String>,
50
50
-
pub blocks: Vec<ProfileView>,
50
50
+
pub blocks: Vec<ProfileView<'static>>,
51
51
}
52
52
use axum::extract::{Query, State};
53
53
use axum::Json;
54
54
-
use lexica::app_bsky::actor::ProfileView;
55
55
-
use lexica::app_bsky::graph::Relationship;
54
54
+
use jacquard_api::app_bsky::actor::ProfileView;
55
55
+
use jacquard_api::app_bsky::graph::Relationship;
56
56
use serde::{Deserialize, Serialize};
57
57
58
58
#[derive(Debug, Serialize)]
59
59
pub struct SubjectRes {
60
60
-
pub subject: ProfileView,
60
60
+
pub subject: ProfileView<'static>,
61
61
#[serde(skip_serializing_if = "Option::is_none")]
62
62
pub cursor: Option<String>,
63
63
}
···
180
180
181
181
#[derive(Debug, Serialize)]
182
182
pub struct GetKnownFollowersRes {
183
183
-
pub subject: ProfileView,
184
184
-
pub followers: Vec<ProfileView>,
183
183
+
pub subject: ProfileView<'static>,
184
184
+
pub followers: Vec<ProfileView<'static>>,
185
185
#[serde(skip_serializing_if = "Option::is_none")]
186
186
pub cursor: Option<String>,
187
187
}
···
238
238
pub struct GetRelationshipsRes {
239
239
#[serde(skip_serializing_if = "Option::is_none")]
240
240
pub actor: Option<String>,
241
241
-
pub relationships: Vec<Relationship>,
241
241
+
pub relationships: Vec<Relationship<'static>>,
242
242
}
243
243
244
244
pub async fn get_relationships(
+3
-3
parakeet/src/xrpc/app_bsky/graph/search.rs
···
3
3
use crate::GlobalState;
4
4
use axum::extract::{Query, State};
5
5
use axum::Json;
6
6
-
use lexica::app_bsky::actor::ProfileView;
6
6
+
use jacquard_api::app_bsky::actor::ProfileView;
7
7
use serde::{Deserialize, Serialize};
8
8
9
9
#[derive(Debug, Deserialize)]
···
15
15
16
16
#[derive(Debug, Serialize)]
17
17
pub struct SearchActorsRes {
18
18
-
pub actors: Vec<ProfileView>,
18
18
+
pub actors: Vec<ProfileView<'static>>,
19
19
#[serde(skip_serializing_if = "Option::is_none")]
20
20
pub cursor: Option<String>,
21
21
}
···
131
131
132
132
#[derive(Debug, Serialize)]
133
133
pub struct SearchStarterPacksRes {
134
134
-
pub starter_packs: Vec<lexica::app_bsky::graph::StarterPackViewBasic>,
134
134
+
pub starter_packs: Vec<jacquard_api::app_bsky::graph::StarterPackViewBasic<'static>>,
135
135
#[serde(skip_serializing_if = "Option::is_none")]
136
136
pub cursor: Option<String>,
137
137
}
+4
-4
parakeet/src/xrpc/app_bsky/graph/starter_packs.rs
···
4
4
use crate::GlobalState;
5
5
use axum::extract::{Query, State};
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::graph::{StarterPackView, StarterPackViewBasic};
7
7
+
use jacquard_api::app_bsky::graph::{StarterPackView, StarterPackViewBasic};
8
8
use serde::{Deserialize, Serialize};
9
9
10
10
#[derive(Debug, Serialize)]
11
11
#[serde(rename_all = "camelCase")]
12
12
pub struct StarterPacksRes {
13
13
-
pub starter_packs: Vec<StarterPackViewBasic>,
13
13
+
pub starter_packs: Vec<StarterPackViewBasic<'static>>,
14
14
#[serde(skip_serializing_if = "Option::is_none")]
15
15
pub cursor: Option<String>,
16
16
}
···
98
98
#[derive(Debug, Serialize)]
99
99
#[serde(rename_all = "camelCase")]
100
100
pub struct GetStarterPackRes {
101
101
-
pub starter_pack: StarterPackView,
101
101
+
pub starter_pack: StarterPackView<'static>,
102
102
}
103
103
104
104
pub async fn get_starter_pack(
···
122
122
#[derive(Debug, Serialize)]
123
123
#[serde(rename_all = "camelCase")]
124
124
pub struct GetStarterPacksRes {
125
125
-
pub starter_packs: Vec<StarterPackViewBasic>,
125
125
+
pub starter_packs: Vec<StarterPackViewBasic<'static>>,
126
126
}
127
127
128
128
#[derive(Debug, Deserialize)]
+2
-2
parakeet/src/xrpc/app_bsky/graph/suggestions.rs
···
3
3
use crate::GlobalState;
4
4
use axum::extract::{Query, State};
5
5
use axum::Json;
6
6
-
use lexica::app_bsky::actor::ProfileView;
6
6
+
use jacquard_api::app_bsky::actor::ProfileView;
7
7
use serde::{Deserialize, Serialize};
8
8
9
9
#[derive(Debug, Deserialize)]
···
14
14
15
15
#[derive(Debug, Serialize)]
16
16
pub struct GetSuggestionsRes {
17
17
-
pub actors: Vec<ProfileView>,
17
17
+
pub actors: Vec<ProfileView<'static>>,
18
18
#[serde(skip_serializing_if = "Option::is_none")]
19
19
pub cursor: Option<String>,
20
20
}
+3
-3
parakeet/src/xrpc/app_bsky/labeler.rs
···
5
5
use axum::Json;
6
6
use axum_extra::extract::Query as ExtraQuery;
7
7
use itertools::Itertools as _;
8
8
-
use lexica::app_bsky::labeler::{LabelerView, LabelerViewDetailed};
8
8
+
use jacquard_api::app_bsky::labeler::{LabelerView, LabelerViewDetailed};
9
9
use serde::{Deserialize, Serialize};
10
10
11
11
#[derive(Debug, Deserialize)]
···
23
23
#[serde(tag = "$type")]
24
24
pub enum ResViewType {
25
25
#[serde(rename = "app.bsky.labeler.defs#labelerView")]
26
26
-
View(LabelerView),
26
26
+
View(LabelerView<'static>),
27
27
#[serde(rename = "app.bsky.labeler.defs#labelerViewDetailed")]
28
28
-
ViewDetailed(LabelerViewDetailed),
28
28
+
ViewDetailed(LabelerViewDetailed<'static>),
29
29
}
30
30
31
31
pub async fn get_services(
+69
-67
parakeet/src/xrpc/app_bsky/mod.rs
···
1
1
use axum::routing::{get, post};
2
2
use axum::Router;
3
3
+
use jacquard_axum::IntoRouter;
4
4
+
use jacquard_api::app_bsky;
3
5
4
6
mod actor;
5
7
mod ageassurance;
···
40
42
pub mod thread_mutes;
41
43
}
42
44
43
43
-
#[rustfmt::skip]
45
45
+
/// Build the app.bsky routes using Jacquard's IntoRouter pattern
44
46
pub fn routes() -> Router<crate::GlobalState> {
45
47
Router::new()
46
46
-
// .route("/app.bsky.actor.getPreferences", get(actor::get_preferences))
47
47
-
// .route("/app.bsky.actor.putPreferences", post(actor::put_preferences))
48
48
-
.route("/app.bsky.actor.getProfile", get(actor::get_profile))
49
49
-
.route("/app.bsky.actor.getProfiles", get(actor::get_profiles))
50
50
-
.route("/app.bsky.actor.searchActors", get(actor::search_actors))
51
51
-
.route("/app.bsky.actor.searchActorsTypeahead", get(actor::search_actors_typeahead))
52
52
-
.route("/app.bsky.actor.getSuggestions", get(actor::get_suggestions))
48
48
+
// Actor endpoints using IntoRouter
49
49
+
.merge(app_bsky::actor::get_profile::GetProfileRequest::into_router(actor::get_profile))
50
50
+
.merge(app_bsky::actor::get_profiles::GetProfilesRequest::into_router(actor::get_profiles))
51
51
+
.merge(app_bsky::actor::search_actors::SearchActorsRequest::into_router(actor::search_actors))
52
52
+
.merge(app_bsky::actor::search_actors_typeahead::SearchActorsTypeaheadRequest::into_router(actor::search_actors_typeahead))
53
53
+
.merge(app_bsky::actor::get_suggestions::GetSuggestionsRequest::into_router(actor::get_suggestions))
54
54
+
55
55
+
// Age assurance endpoints - need to be converted to use ExtractXrpc
53
56
.route("/app.bsky.ageassurance.getConfig", get(ageassurance::get_config))
54
57
.route("/app.bsky.ageassurance.getState", get(ageassurance::get_state))
55
55
-
.route("/app.bsky.bookmark.createBookmark", post(bookmark::create_bookmark))
56
56
-
.route("/app.bsky.bookmark.deleteBookmark", post(bookmark::delete_bookmark))
57
57
-
.route("/app.bsky.bookmark.getBookmarks", get(bookmark::get_bookmarks))
58
58
-
.route("/app.bsky.feed.getActorFeeds", get(feed::feedgen::get_actor_feeds))
59
59
-
.route("/app.bsky.feed.getActorLikes", get(feed::likes::get_actor_likes))
60
60
-
.route("/app.bsky.feed.getAuthorFeed", get(feed::get_timeline::get_author_feed))
61
61
-
.route("/app.bsky.feed.getFeed", get(feed::posts::get_feed))
62
62
-
.route("/app.bsky.feed.getFeedGenerator", get(feed::feedgen::get_feed_generator))
63
63
-
.route("/app.bsky.feed.getFeedGenerators", get(feed::feedgen::get_feed_generators))
64
64
-
.route("/app.bsky.feed.getLikes", get(feed::likes::get_likes))
65
65
-
.route("/app.bsky.feed.getListFeed", get(feed::posts::get_list_feed))
66
66
-
.route("/app.bsky.feed.getPostThread", get(feed::posts::get_post_thread))
67
67
-
.route("/app.bsky.feed.getPosts", get(feed::posts::get_posts))
68
68
-
.route("/app.bsky.feed.getQuotes", get(feed::posts::get_quotes))
69
69
-
.route("/app.bsky.feed.getRepostedBy", get(feed::posts::get_reposted_by))
70
70
-
.route("/app.bsky.feed.getSuggestedFeeds", get(feed::feedgen::get_suggested_feeds))
71
71
-
.route("/app.bsky.feed.getTimeline", get(feed::get_timeline::get_timeline))
72
72
-
.route("/app.bsky.feed.searchPosts", get(feed::search::search_posts))
73
73
-
.route("/app.bsky.graph.getActorStarterPacks", get(graph::starter_packs::get_actor_starter_packs))
74
74
-
.route("/app.bsky.graph.getBlocks", get(graph::relations::get_blocks))
75
75
-
.route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers))
76
76
-
.route("/app.bsky.graph.getFollows", get(graph::relations::get_follows))
77
77
-
.route("/app.bsky.graph.getKnownFollowers", get(graph::relations::get_known_followers))
58
58
+
59
59
+
// Bookmark endpoints using IntoRouter
60
60
+
.merge(app_bsky::bookmark::create_bookmark::CreateBookmarkRequest::into_router(bookmark::create_bookmark))
61
61
+
.merge(app_bsky::bookmark::delete_bookmark::DeleteBookmarkRequest::into_router(bookmark::delete_bookmark))
62
62
+
.merge(app_bsky::bookmark::get_bookmarks::GetBookmarksRequest::into_router(bookmark::get_bookmarks))
63
63
+
64
64
+
// Feed endpoints using IntoRouter
65
65
+
.merge(app_bsky::feed::get_actor_feeds::GetActorFeedsRequest::into_router(feed::feedgen::get_actor_feeds))
66
66
+
.merge(app_bsky::feed::get_actor_likes::GetActorLikesRequest::into_router(feed::likes::get_actor_likes))
67
67
+
.merge(app_bsky::feed::get_author_feed::GetAuthorFeedRequest::into_router(feed::get_timeline::get_author_feed))
68
68
+
.merge(app_bsky::feed::get_feed::GetFeedRequest::into_router(feed::posts::get_feed))
69
69
+
.merge(app_bsky::feed::get_feed_generator::GetFeedGeneratorRequest::into_router(feed::feedgen::get_feed_generator))
70
70
+
.merge(app_bsky::feed::get_feed_generators::GetFeedGeneratorsRequest::into_router(feed::feedgen::get_feed_generators))
71
71
+
.merge(app_bsky::feed::get_likes::GetLikesRequest::into_router(feed::likes::get_likes))
72
72
+
.merge(app_bsky::feed::get_list_feed::GetListFeedRequest::into_router(feed::posts::get_list_feed))
73
73
+
.merge(app_bsky::feed::get_post_thread::GetPostThreadRequest::into_router(feed::posts::get_post_thread))
74
74
+
.merge(app_bsky::feed::get_posts::GetPostsRequest::into_router(feed::posts::get_posts))
75
75
+
.merge(app_bsky::feed::get_quotes::GetQuotesRequest::into_router(feed::posts::get_quotes))
76
76
+
.merge(app_bsky::feed::get_reposted_by::GetRepostedByRequest::into_router(feed::posts::get_reposted_by))
77
77
+
.merge(app_bsky::feed::get_suggested_feeds::GetSuggestedFeedsRequest::into_router(feed::feedgen::get_suggested_feeds))
78
78
+
.merge(app_bsky::feed::get_timeline::GetTimelineRequest::into_router(feed::get_timeline::get_timeline))
79
79
+
.merge(app_bsky::feed::search_posts::SearchPostsRequest::into_router(feed::search::search_posts))
80
80
+
81
81
+
// Graph endpoints using IntoRouter
82
82
+
.merge(app_bsky::graph::get_followers::GetFollowersRequest::into_router(graph::relations::get_followers))
83
83
+
.merge(app_bsky::graph::get_follows::GetFollowsRequest::into_router(graph::relations::get_follows))
84
84
+
.merge(app_bsky::graph::get_known_followers::GetKnownFollowersRequest::into_router(graph::relations::get_known_followers))
85
85
+
.merge(app_bsky::graph::get_list::GetListRequest::into_router(graph::lists::get_list))
86
86
+
.merge(app_bsky::graph::get_lists::GetListsRequest::into_router(graph::lists::get_lists))
87
87
+
.merge(app_bsky::graph::get_mutes::GetMutesRequest::into_router(graph::mutes::get_mutes))
88
88
+
.merge(app_bsky::graph::get_relationships::GetRelationshipsRequest::into_router(graph::relations::get_relationships))
89
89
+
.merge(app_bsky::graph::get_starter_pack::GetStarterPackRequest::into_router(graph::starter_packs::get_starter_pack))
90
90
+
.merge(app_bsky::graph::get_starter_packs::GetStarterPacksRequest::into_router(graph::starter_packs::get_starter_packs))
91
91
+
.merge(app_bsky::graph::get_suggested_follows_by_actor::GetSuggestedFollowsByActorRequest::into_router(graph::suggestions::get_suggested_follows_by_actor))
92
92
+
.merge(app_bsky::graph::search_starter_packs::SearchStarterPacksRequest::into_router(graph::search::search_starter_packs))
93
93
+
94
94
+
// Custom endpoints not in jacquard yet - keep using old routing
78
95
.route("/app.bsky.graph.getList", get(graph::lists::get_list))
79
79
-
.route("/app.bsky.graph.getListBlocks", get(graph::lists::get_list_blocks))
80
80
-
.route("/app.bsky.graph.getListMutes", get(graph::lists::get_list_mutes))
81
81
-
.route("/app.bsky.graph.getLists", get(graph::lists::get_lists))
82
82
-
.route("/app.bsky.graph.getMutes", get(graph::mutes::get_mutes))
83
83
-
.route("/app.bsky.graph.getRelationships", get(graph::relations::get_relationships))
84
84
-
.route("/app.bsky.graph.getStarterPack", get(graph::starter_packs::get_starter_pack))
85
85
-
.route("/app.bsky.graph.getStarterPacks", get(graph::starter_packs::get_starter_packs))
86
86
-
.route("/app.bsky.graph.getSuggestedFollowsByActor", get(graph::suggestions::get_suggested_follows_by_actor))
87
87
-
.route("/app.bsky.graph.muteActor", post(graph::mutes::mute_actor))
88
88
-
.route("/app.bsky.graph.muteActorList", post(graph::mutes::mute_actor_list))
89
89
-
.route("/app.bsky.graph.muteThread", post(graph::thread_mutes::mute_thread))
90
90
-
.route("/app.bsky.graph.searchStarterPacks", get(graph::search::search_starter_packs))
91
91
-
.route("/app.bsky.graph.unmuteActor", post(graph::mutes::unmute_actor))
92
92
-
.route("/app.bsky.graph.unmuteActorList", post(graph::mutes::unmute_actor_list))
93
93
-
.route("/app.bsky.graph.unmuteThread", post(graph::thread_mutes::unmute_thread))
94
94
-
.route("/app.bsky.labeler.getServices", get(labeler::get_services))
95
95
-
.route("/app.bsky.unspecced.getTrendingTopics", get(unspecced::get_trending_topics))
96
96
-
.route("/app.bsky.unspecced.getConfig", get(unspecced::get_config))
97
97
-
.route("/app.bsky.unspecced.getSuggestedFeeds", get(unspecced::get_suggested_feeds))
98
98
-
.route("/app.bsky.unspecced.getSuggestedUsers", get(unspecced::get_suggested_users))
99
99
-
.route("/app.bsky.unspecced.getPopularFeedGenerators", get(unspecced::get_popular_feed_generators))
100
100
-
.route("/app.bsky.unspecced.getSuggestedStarterPacks", get(unspecced::get_suggested_starter_packs))
101
101
-
// TODO: app.bsky.notification.getPreferences
102
102
-
.route("/app.bsky.notification.getUnreadCount", get(notification::get_unread_count))
103
103
-
// TODO: app.bsky.notification.listActivitySubscriptions
104
104
-
.route("/app.bsky.notification.listNotifications", get(notification::list_notifications))
105
105
-
// TODO: app.bsky.notification.putActivitySubscriptions
106
106
-
// TODO: app.bsky.notification.putPreferences
107
107
-
// TODO: app.bsky.notification.putPreferencesV2
108
108
-
.route("/app.bsky.notification.updateSeen", post(notification::update_seen))
96
96
+
// TODO: Implement getSuggestedDidYouFollows
97
97
+
// .route("/app.bsky.graph.getSuggestedDidYouFollows", get(graph::suggestions::get_suggested_did_you_follows))
98
98
+
// TODO: Implement getThreadMutes
99
99
+
// .route("/app.bsky.graph.getThreadMutes", get(graph::thread_mutes::get_thread_mutes))
100
100
+
101
101
+
// Labeler endpoints using IntoRouter
102
102
+
.merge(app_bsky::labeler::get_services::GetServicesRequest::into_router(labeler::get_services))
103
103
+
104
104
+
// Notification endpoints using IntoRouter
105
105
+
.merge(app_bsky::notification::list_notifications::ListNotificationsRequest::into_router(notification::list_notifications))
106
106
+
.merge(app_bsky::notification::get_unread_count::GetUnreadCountRequest::into_router(notification::get_unread_count))
107
107
+
.merge(app_bsky::notification::update_seen::UpdateSeenRequest::into_router(notification::update_seen))
108
108
+
.merge(app_bsky::notification::register_push::RegisterPushRequest::into_router(notification::register_push))
109
109
+
110
110
+
// Unspecced endpoints - need to be converted to use ExtractXrpc
109
111
.route("/app.bsky.unspecced.getPostThreadV2", get(unspecced::thread_v2::get_post_thread_v2))
110
112
.route("/app.bsky.unspecced.getPostThreadOtherV2", get(unspecced::thread_v2::get_post_thread_other_v2))
111
111
-
}
112
112
-
113
113
-
#[expect(dead_code)]
114
114
-
async fn not_implemented() -> axum::http::StatusCode {
115
115
-
axum::http::StatusCode::NOT_IMPLEMENTED
116
116
-
}
113
113
+
// TODO: Implement getSuggestionsSkeleton
114
114
+
// .route("/app.bsky.unspecced.getSuggestionsSkeleton", get(unspecced::get_suggestions_skeleton))
115
115
+
.route("/app.bsky.unspecced.getSuggestedFeeds", get(unspecced::get_suggested_feeds))
116
116
+
// TODO: Implement getVendorAdvertisements
117
117
+
// .route("/app.bsky.unspecced.getVendorAdvertisements", get(unspecced::handlers::get_vendor_advertisements))
118
118
+
}
+99
-189
parakeet/src/xrpc/app_bsky/notification.rs
···
1
1
use crate::common::errors::{Error, XrpcResult};
2
2
use crate::common::auth::{AtpAcceptLabelers, AtpAuth};
3
3
use crate::GlobalState;
4
4
-
use axum::extract::{Query, State};
4
4
+
use axum::extract::State;
5
5
+
use axum::http::StatusCode;
5
6
use axum::Json;
7
7
+
use jacquard_axum::ExtractXrpc;
8
8
+
use jacquard_api::app_bsky::notification::{
9
9
+
list_notifications::{ListNotificationsRequest, ListNotificationsOutput, Notification},
10
10
+
update_seen::{UpdateSeenRequest, UpdateSeenResponse},
11
11
+
register_push::{RegisterPushRequest, RegisterPushResponse},
12
12
+
get_unread_count::{GetUnreadCountRequest, GetUnreadCountResponse, GetUnreadCountOutput},
13
13
+
};
14
14
+
use jacquard_api::app_bsky::actor::ProfileView;
15
15
+
use jacquard_common::IntoStatic;
6
16
use chrono::{DateTime, Utc};
7
17
use serde::{Deserialize, Serialize};
8
8
-
9
9
-
#[derive(Debug, Deserialize)]
10
10
-
pub struct ListNotificationsQuery {
11
11
-
#[serde(default)]
12
12
-
pub limit: Option<usize>,
13
13
-
#[serde(default)]
14
14
-
pub cursor: Option<String>,
15
15
-
#[serde(default)]
16
16
-
#[expect(dead_code, reason = "seen_at parameter accepted for API compatibility but not yet used for filtering")]
17
17
-
pub seen_at: Option<DateTime<Utc>>,
18
18
-
}
19
19
-
20
20
-
#[derive(Debug, Serialize)]
21
21
-
pub struct ListNotificationsResponse {
22
22
-
pub notifications: Vec<Notification>,
23
23
-
#[serde(skip_serializing_if = "Option::is_none")]
24
24
-
pub cursor: Option<String>,
25
25
-
#[serde(rename = "seenAt", skip_serializing_if = "Option::is_none")]
26
26
-
pub seen_at: Option<String>,
27
27
-
pub priority: bool,
28
28
-
}
29
29
-
30
30
-
#[derive(Debug, Serialize)]
31
31
-
pub struct Notification {
32
32
-
pub uri: String,
33
33
-
pub cid: String,
34
34
-
pub author: NotificationAuthor,
35
35
-
pub reason: String,
36
36
-
#[serde(rename = "reasonSubject", skip_serializing_if = "Option::is_none")]
37
37
-
pub reason_subject: Option<String>,
38
38
-
pub record: serde_json::Value,
39
39
-
#[serde(rename = "isRead")]
40
40
-
pub is_read: bool,
41
41
-
#[serde(rename = "indexedAt")]
42
42
-
pub indexed_at: String,
43
43
-
#[serde(skip_serializing_if = "Option::is_none")]
44
44
-
pub labels: Option<Vec<serde_json::Value>>,
45
45
-
}
46
46
-
47
47
-
#[derive(Debug, Serialize)]
48
48
-
pub struct NotificationAuthor {
49
49
-
pub did: String,
50
50
-
pub handle: String,
51
51
-
#[serde(rename = "displayName", skip_serializing_if = "Option::is_none")]
52
52
-
pub display_name: Option<String>,
53
53
-
#[serde(skip_serializing_if = "Option::is_none")]
54
54
-
pub avatar: Option<String>,
55
55
-
#[serde(skip_serializing_if = "Option::is_none")]
56
56
-
pub associated: Option<serde_json::Value>,
57
57
-
#[serde(skip_serializing_if = "Option::is_none")]
58
58
-
pub viewer: Option<serde_json::Value>,
59
59
-
#[serde(skip_serializing_if = "Option::is_none")]
60
60
-
pub labels: Option<Vec<serde_json::Value>>,
61
61
-
#[serde(skip_serializing_if = "Option::is_none")]
62
62
-
pub description: Option<String>,
63
63
-
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
64
64
-
pub created_at: Option<String>,
65
65
-
#[serde(rename = "indexedAt", skip_serializing_if = "Option::is_none")]
66
66
-
pub indexed_at: Option<String>,
67
67
-
}
68
18
69
19
pub async fn list_notifications(
70
20
State(state): State<GlobalState>,
21
21
+
ExtractXrpc(req): ExtractXrpc<ListNotificationsRequest>,
71
22
AtpAcceptLabelers(labelers): AtpAcceptLabelers,
72
23
maybe_auth: Option<AtpAuth>,
73
73
-
Query(query): Query<ListNotificationsQuery>,
74
74
-
) -> XrpcResult<Json<ListNotificationsResponse>> {
24
24
+
) -> Result<Json<ListNotificationsOutput<'static>>, StatusCode> {
75
25
// Check if user is authenticated
76
76
-
let auth = maybe_auth.ok_or_else(|| {
77
77
-
Error::new(
78
78
-
axum::http::StatusCode::UNAUTHORIZED,
79
79
-
"AuthenticationRequired",
80
80
-
Some("Authentication required".to_owned()),
81
81
-
)
82
82
-
})?;
26
26
+
let auth = maybe_auth.ok_or(StatusCode::UNAUTHORIZED)?;
83
27
84
28
let start = std::time::Instant::now();
85
29
86
86
-
let limit = query.limit.unwrap_or(50).min(100) + 1; // +1 to check for more results
30
30
+
let limit = req.limit.unwrap_or(50).min(100) + 1; // +1 to check for more results
87
31
88
32
// Parse cursor if provided (notification ID)
89
89
-
let cursor_id = query
33
33
+
let cursor_id = req
90
34
.cursor
91
35
.as_ref()
92
36
.filter(|c| !c.is_empty())
···
94
38
95
39
// Get database connection
96
40
let conn_start = std::time::Instant::now();
97
97
-
let mut conn = state.pool.get().await.map_err(|e| {
98
98
-
Error::new(
99
99
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
100
100
-
"DatabaseError",
101
101
-
Some(format!("Failed to get database connection: {}", e)),
102
102
-
)
103
103
-
})?;
41
41
+
let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
104
42
tracing::info!(" → Connection pool: {:.1}ms", conn_start.elapsed().as_secs_f64() * 1000.0);
105
43
106
44
// Resolve DID to actor_id using IdCache (fast path) or database (slow path)
···
121
59
.optional()
122
60
.map_err(|e| {
123
61
Error::new(
124
124
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
62
62
+
StatusCode::INTERNAL_SERVER_ERROR,
125
63
"DatabaseError",
126
64
Some(format!("Failed to resolve actor: {}", e)),
127
65
)
···
129
67
130
68
let id = id.ok_or_else(|| {
131
69
Error::new(
132
132
-
axum::http::StatusCode::NOT_FOUND,
133
133
-
"ActorNotFound",
134
134
-
Some("Actor not found".to_owned()),
70
70
+
StatusCode::NOT_FOUND,
71
71
+
"NotFound",
72
72
+
Some("Actor not found".to_string()),
135
73
)
136
74
})?;
137
75
···
150
88
let mut db_notifications =
151
89
state.notification_entity.list_notifications_raw(actor_id, cursor_id, limit as i64)
152
90
.await
153
153
-
.map_err(|e| {
154
154
-
Error::new(
155
155
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
156
156
-
"DatabaseError",
157
157
-
Some(format!("Failed to fetch notifications: {}", e)),
158
158
-
)
159
159
-
})?;
91
91
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
160
92
tracing::info!(" → Fetch notifications query: {:.1}ms ({} results)", notifs_start.elapsed().as_secs_f64() * 1000.0, db_notifications.len());
161
93
162
94
// Check if there are more results
163
163
-
let has_more = db_notifications.len() >= limit;
95
95
+
let has_more = db_notifications.len() >= limit as usize;
164
96
let next_cursor_id = if has_more {
165
97
db_notifications.pop().map(|n| n.id)
166
98
} else {
···
171
103
let seen_at_start = std::time::Instant::now();
172
104
let notification_state = state.notification_entity.get_notification_state(actor_id)
173
105
.await
174
174
-
.map_err(|e| {
175
175
-
Error::new(
176
176
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
177
177
-
"DatabaseError",
178
178
-
Some(format!("Failed to fetch notification state: {}", e)),
179
179
-
)
180
180
-
})?;
106
106
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
181
107
tracing::info!(" → Get notification state: {:.1}ms", seen_at_start.elapsed().as_secs_f64() * 1000.0);
182
108
183
109
let seen_at = notification_state.and_then(|s| s.seen_at);
···
196
122
let actor_id_vec: Vec<i32> = actor_ids_to_resolve.into_iter().collect();
197
123
let profiles = state.profile_entity.get_profiles_by_ids(&actor_id_vec)
198
124
.await
199
199
-
.map_err(|_| {
200
200
-
Error::new(
201
201
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
202
202
-
"InternalServerError".to_string(),
203
203
-
Some("Database error resolving actor DIDs".to_string()),
204
204
-
)
205
205
-
})?;
125
125
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
206
126
let actor_did_map: std::collections::HashMap<i32, String> = profiles
207
127
.into_iter()
208
128
.map(|actor| (actor.id, actor.did))
···
222
142
.await;
223
143
224
144
// Convert Vec to HashMap for compatibility with existing code
225
225
-
let profiles_map: std::collections::HashMap<String, lexica::app_bsky::actor::ProfileViewDetailed> =
145
145
+
let profiles_map: std::collections::HashMap<String, jacquard_api::app_bsky::actor::ProfileViewDetailed> =
226
146
profiles_vec.into_iter()
227
227
-
.map(|profile| (profile.did.clone(), profile))
147
147
+
.map(|profile| (profile.did.as_str().to_string(), profile))
228
148
.collect();
229
149
tracing::info!(" → Hydrate profiles: {:.1}ms ({} profiles)", profile_start.elapsed().as_secs_f64() * 1000.0, profiles_map.len());
230
150
···
278
198
// Use ProfileEntity for additional actors
279
199
let additional_profiles = state.profile_entity.get_profiles_by_ids(&actor_id_vec)
280
200
.await
281
281
-
.map_err(|_| {
282
282
-
Error::new(
283
283
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
284
284
-
"InternalServerError".to_string(),
285
285
-
Some("Database error resolving parent/root actor DIDs".to_string()),
286
286
-
)
287
287
-
})?;
201
201
+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
288
202
let additional_dids: std::collections::HashMap<i32, String> = additional_profiles
289
203
.into_iter()
290
204
.map(|actor| (actor.id, actor.did))
···
313
227
314
228
let profile = profiles_map.get(author_did);
315
229
316
316
-
let author = if let Some(profile_view) = profile {
317
317
-
NotificationAuthor {
318
318
-
did: profile_view.did.clone(),
319
319
-
handle: profile_view.handle.clone(),
320
320
-
display_name: profile_view.display_name.clone(),
321
321
-
avatar: profile_view.avatar.clone(),
322
322
-
associated: profile_view
323
323
-
.associated
324
324
-
.as_ref()
325
325
-
.and_then(|a| serde_json::to_value(a).ok()),
326
326
-
viewer: profile_view
327
327
-
.viewer
328
328
-
.as_ref()
329
329
-
.and_then(|v| serde_json::to_value(v).ok()),
330
330
-
labels: Some(
331
331
-
profile_view
332
332
-
.labels
333
333
-
.iter()
334
334
-
.filter_map(|l| serde_json::to_value(l).ok())
335
335
-
.collect(),
336
336
-
),
337
337
-
description: profile_view.description.clone(),
338
338
-
created_at: Some(profile_view.created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
339
339
-
indexed_at: Some(profile_view.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
340
340
-
}
230
230
+
let author = if let Some(profile_detailed) = profile {
231
231
+
// Convert ProfileViewDetailed to ProfileView
232
232
+
ProfileView {
233
233
+
did: profile_detailed.did.clone(),
234
234
+
handle: profile_detailed.handle.clone(),
235
235
+
display_name: profile_detailed.display_name.clone(),
236
236
+
avatar: profile_detailed.avatar.clone(),
237
237
+
associated: profile_detailed.associated.clone(),
238
238
+
viewer: profile_detailed.viewer.clone(),
239
239
+
labels: profile_detailed.labels.clone(),
240
240
+
created_at: profile_detailed.created_at.clone(),
241
241
+
description: profile_detailed.description.clone(),
242
242
+
indexed_at: profile_detailed.indexed_at.clone(),
243
243
+
verification: None,
244
244
+
debug: None,
245
245
+
pronouns: None,
246
246
+
status: None,
247
247
+
extra_data: None,
248
248
+
}.into_static()
341
249
} else {
342
342
-
// Fallback if profile not found
343
343
-
NotificationAuthor {
344
344
-
did: author_did.clone(),
345
345
-
handle: "handle.invalid".to_string(),
346
346
-
display_name: None,
347
347
-
avatar: None,
348
348
-
associated: None,
349
349
-
viewer: None,
350
350
-
labels: None,
351
351
-
description: None,
352
352
-
created_at: None,
353
353
-
indexed_at: None,
354
354
-
}
250
250
+
// Fallback if profile not found - build a minimal ProfileView
251
251
+
ProfileView::new()
252
252
+
.did(jacquard_common::types::string::Did::new(author_did).unwrap())
253
253
+
.handle(jacquard_common::types::string::Handle::new("handle.invalid").unwrap())
254
254
+
.build()
255
255
+
.into_static()
355
256
};
356
257
357
258
// Reconstruct the URI from normalized components
···
377
278
.unwrap_or(false);
378
279
379
280
notifications.push(Notification {
380
380
-
uri,
281
281
+
uri: jacquard_common::types::string::AtUri::new(&uri).unwrap().into_static(),
381
282
// Use the real CID from database (already stored for all record types)
382
382
-
cid: parakeet_db::cid_util::digest_to_record_cid_string(¬if.record_cid)
383
383
-
.unwrap_or_else(|| String::from("bafyrei_invalid_cid")),
283
283
+
cid: jacquard_common::types::string::Cid::new(
284
284
+
parakeet_db::cid_util::digest_to_record_cid_string(¬if.record_cid)
285
285
+
.unwrap_or_else(|| String::from("bafyrei_invalid_cid"))
286
286
+
.as_bytes()
287
287
+
).unwrap().into_static(),
384
288
author,
385
385
-
reason: notif.reason.to_string(),
386
386
-
reason_subject,
387
387
-
record,
289
289
+
reason: notif.reason.to_string().into(),
290
290
+
reason_subject: reason_subject.map(|s| jacquard_common::types::string::AtUri::new(&s).unwrap().into_static()),
291
291
+
// Convert serde_json::Value to jacquard Data by serializing and deserializing
292
292
+
record: serde_json::from_value::<jacquard_common::types::value::Data>(record)
293
293
+
.unwrap_or(jacquard_common::types::value::Data::Null),
388
294
is_read,
389
389
-
indexed_at: notif.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
295
295
+
indexed_at: jacquard_common::types::string::Datetime::new(
296
296
+
¬if.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
297
297
+
).unwrap(),
390
298
labels: Some(Vec::new()),
391
391
-
});
299
299
+
extra_data: None,
300
300
+
}.into_static());
392
301
}
393
302
tracing::info!(" → Build responses: {:.1}ms (record_fetch: {:.1}ms)", build_start.elapsed().as_secs_f64() * 1000.0, record_fetch_time);
394
303
···
400
309
401
310
tracing::info!("→ listNotifications: {:.1}ms total ({} notifications)", start.elapsed().as_secs_f64() * 1000.0, notifications.len());
402
311
403
403
-
Ok(Json(ListNotificationsResponse {
312
312
+
Ok(Json(ListNotificationsOutput {
404
313
notifications,
405
405
-
cursor,
406
406
-
seen_at: seen_at_response,
407
407
-
priority: false,
408
408
-
}))
409
409
-
}
410
410
-
411
411
-
#[derive(Debug, Deserialize)]
412
412
-
pub struct GetUnreadCountQuery {
413
413
-
#[serde(default)]
414
414
-
#[expect(dead_code, reason = "seen_at parameter accepted for API compatibility but not yet used for calculation")]
415
415
-
pub seen_at: Option<DateTime<Utc>>,
314
314
+
cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)),
315
315
+
seen_at: seen_at.map(|dt| jacquard_common::types::datetime::Datetime::from(dt.fixed_offset())),
316
316
+
priority: Some(false),
317
317
+
extra_data: None,
318
318
+
}.into_static()))
416
319
}
417
320
418
418
-
#[derive(Debug, Serialize)]
419
419
-
pub struct GetUnreadCountResponse {
420
420
-
pub count: i64,
421
421
-
}
321
321
+
// GetUnreadCountResponse is imported from jacquard_api
422
322
423
323
pub async fn get_unread_count(
424
324
State(state): State<GlobalState>,
425
325
maybe_auth: Option<AtpAuth>,
426
426
-
Query(_query): Query<GetUnreadCountQuery>,
427
427
-
) -> XrpcResult<Json<GetUnreadCountResponse>> {
326
326
+
ExtractXrpc(_query): ExtractXrpc<GetUnreadCountRequest>,
327
327
+
) -> XrpcResult<Json<GetUnreadCountOutput<'static>>> {
428
328
// Check if user is authenticated
429
329
let auth = maybe_auth.ok_or_else(|| {
430
330
Error::new(
···
437
337
// Get database connection
438
338
let mut conn = state.pool.get().await.map_err(|e| {
439
339
Error::new(
440
440
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
340
340
+
StatusCode::INTERNAL_SERVER_ERROR,
441
341
"DatabaseError",
442
342
Some(format!("Failed to get database connection: {}", e)),
443
343
)
···
460
360
.optional()
461
361
.map_err(|e| {
462
362
Error::new(
463
463
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
363
363
+
StatusCode::INTERNAL_SERVER_ERROR,
464
364
"DatabaseError",
465
365
Some(format!("Failed to resolve actor: {}", e)),
466
366
)
···
468
368
469
369
let id = id.ok_or_else(|| {
470
370
Error::new(
471
471
-
axum::http::StatusCode::NOT_FOUND,
472
472
-
"ActorNotFound",
473
473
-
Some("Actor not found".to_owned()),
371
371
+
StatusCode::NOT_FOUND,
372
372
+
"NotFound",
373
373
+
Some("Actor not found".to_string()),
474
374
)
475
375
})?;
476
376
···
494
394
)
495
395
})?;
496
396
497
497
-
Ok(Json(GetUnreadCountResponse { count }))
397
397
+
Ok(Json(GetUnreadCountOutput { count, extra_data: None }.into_static()))
498
398
}
499
399
500
400
#[derive(Debug, Deserialize)]
···
520
420
// Get database connection
521
421
let mut conn = state.pool.get().await.map_err(|e| {
522
422
Error::new(
523
523
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
423
423
+
StatusCode::INTERNAL_SERVER_ERROR,
524
424
"DatabaseError",
525
425
Some(format!("Failed to get database connection: {}", e)),
526
426
)
···
543
443
.optional()
544
444
.map_err(|e| {
545
445
Error::new(
546
546
-
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
446
446
+
StatusCode::INTERNAL_SERVER_ERROR,
547
447
"DatabaseError",
548
448
Some(format!("Failed to resolve actor: {}", e)),
549
449
)
···
551
451
552
452
let id = id.ok_or_else(|| {
553
453
Error::new(
554
554
-
axum::http::StatusCode::NOT_FOUND,
555
555
-
"ActorNotFound",
556
556
-
Some("Actor not found".to_owned()),
454
454
+
StatusCode::NOT_FOUND,
455
455
+
"NotFound",
456
456
+
Some("Actor not found".to_string()),
557
457
)
558
458
})?;
559
459
···
704
604
"uri": fallback_uri,
705
605
})
706
606
}
607
607
+
608
608
+
pub async fn register_push(
609
609
+
State(_state): State<GlobalState>,
610
610
+
_auth: AtpAuth,
611
611
+
ExtractXrpc(_req): ExtractXrpc<RegisterPushRequest>,
612
612
+
) -> Result<Json<RegisterPushResponse>, StatusCode> {
613
613
+
// Push notification registration not implemented yet
614
614
+
// This would typically store device tokens for push notifications
615
615
+
Ok(Json(RegisterPushResponse))
616
616
+
}
+19
-15
parakeet/src/xrpc/app_bsky/unspecced/handlers.rs
···
4
4
use crate::GlobalState;
5
5
use axum::extract::{Query, State};
6
6
use axum::Json;
7
7
-
use lexica::app_bsky::actor::ProfileView;
8
8
-
use lexica::app_bsky::feed::GeneratorView;
9
9
-
use lexica::app_bsky::graph::StarterPackViewBasic;
7
7
+
use jacquard_api::app_bsky::actor::ProfileView;
8
8
+
use jacquard_api::app_bsky::feed::GeneratorView;
9
9
+
use jacquard_api::app_bsky::graph::StarterPackViewBasic;
10
10
+
use jacquard_common::IntoStatic;
10
11
use parakeet_db::models::ProfileStats;
11
12
use serde::{Deserialize, Serialize};
12
13
···
120
121
121
122
#[derive(Debug, Serialize)]
122
123
pub struct GetSuggestedFeedsResponse {
123
123
-
pub feeds: Vec<GeneratorView>,
124
124
+
pub feeds: Vec<GeneratorView<'static>>,
124
125
}
125
126
126
127
/// Handles the app.bsky.unspecced.getSuggestedFeeds endpoint
···
200
201
}
201
202
202
203
#[derive(Debug, Serialize)]
203
203
-
pub struct GetSuggestedUsersResponse {
204
204
-
pub actors: Vec<ProfileView>,
204
204
+
pub struct GetSuggestedUsersResponse<'a> {
205
205
+
pub actors: Vec<ProfileView<'a>>,
205
206
}
206
207
207
208
/// Handles the app.bsky.unspecced.getSuggestedUsers endpoint
···
213
214
AtpAcceptLabelers(labelers): AtpAcceptLabelers,
214
215
maybe_auth: Option<AtpAuth>,
215
216
Query(query): Query<GetSuggestedUsersQuery>,
216
216
-
) -> XrpcResult<Json<GetSuggestedUsersResponse>> {
217
217
+
) -> XrpcResult<Json<GetSuggestedUsersResponse<'static>>> {
217
218
let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize;
218
219
// Category parameter is accepted but ignored for now
219
220
···
307
308
// Create map for order preservation
308
309
let mut profiles_map: HashMap<String, ProfileView> = profiles_vec
309
310
.into_iter()
310
310
-
.map(|profile| (profile.did.clone(), profile))
311
311
+
.map(|profile| (profile.did.as_str().to_string(), profile))
311
312
.collect();
312
313
313
314
// Maintain pagination order
···
332
333
pub struct GetPopularFeedGeneratorsResponse {
333
334
#[serde(skip_serializing_if = "Option::is_none")]
334
335
pub cursor: Option<String>,
335
335
-
pub feeds: Vec<GeneratorView>,
336
336
+
pub feeds: Vec<GeneratorView<'static>>,
336
337
}
337
338
338
339
/// Handles the app.bsky.unspecced.getPopularFeedGenerators endpoint
···
487
488
488
489
#[derive(Debug, Serialize)]
489
490
#[serde(rename_all = "camelCase")]
490
490
-
pub struct GetSuggestedStarterPacksResponse {
491
491
-
pub starter_packs: Vec<StarterPackViewBasic>,
491
491
+
pub struct GetSuggestedStarterPacksResponse<'a> {
492
492
+
pub starter_packs: Vec<StarterPackViewBasic<'a>>,
492
493
}
493
494
494
495
/// Handles the app.bsky.unspecced.getSuggestedStarterPacks endpoint
···
500
501
AtpAcceptLabelers(labelers): AtpAcceptLabelers,
501
502
maybe_auth: Option<AtpAuth>,
502
503
Query(query): Query<GetSuggestedStarterPacksQuery>,
503
503
-
) -> XrpcResult<Json<GetSuggestedStarterPacksResponse>> {
504
504
+
) -> XrpcResult<Json<GetSuggestedStarterPacksResponse<'static>>> {
504
505
let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize;
505
506
506
507
// Compute rankings (no caching - recalculated each time)
···
577
578
for uri in page_uris {
578
579
if let Ok(Some(pack)) = state.starterpack_entity.get_by_uri(&uri, maybe_did.as_deref()).await {
579
580
// Convert StarterPackView to StarterPackViewBasic
580
580
-
let basic = lexica::app_bsky::graph::StarterPackViewBasic {
581
581
+
// Note: StarterPackView doesn't have list_item_count directly, get it from list if available
582
582
+
let list_item_count = pack.list.as_ref().and_then(|l| l.list_item_count);
583
583
+
let basic = jacquard_api::app_bsky::graph::StarterPackViewBasic {
581
584
uri: pack.uri,
582
585
cid: pack.cid,
583
586
record: pack.record,
584
587
creator: pack.creator,
585
585
-
list_item_count: pack.list_item_count,
588
588
+
list_item_count,
586
589
joined_week_count: pack.joined_week_count,
587
590
joined_all_time_count: pack.joined_all_time_count,
588
591
labels: pack.labels,
589
592
indexed_at: pack.indexed_at,
590
590
-
};
593
593
+
extra_data: None,
594
594
+
}.into_static();
591
595
starter_packs.push(basic);
592
596
}
593
597
}
+11
-8
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/models.rs
···
1
1
-
use lexica::app_bsky::feed::{PostView, ThreadgateView};
1
1
+
use jacquard_api::app_bsky::feed::{PostView, ThreadgateView};
2
2
+
use jacquard_api::app_bsky::unspecced::get_post_thread_v2::{ThreadItem, ThreadItemValue};
2
3
use serde::{Deserialize, Serialize};
3
4
4
4
-
// Re-export the correct types from lexica
5
5
-
pub use lexica::app_bsky::unspecced::{ThreadItemPost, ThreadV2Item, ThreadV2ItemType};
5
5
+
// Use jacquard types - renaming for compatibility
6
6
+
pub type ThreadV2Item<'a> = ThreadItem<'a>;
7
7
+
pub type ThreadItemPost<'a> = PostView<'a>;
8
8
+
pub type ThreadV2ItemType<'a> = ThreadItemValue<'a>;
6
9
7
10
/// Post thread sorting options
8
11
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
···
20
23
#[derive(Debug, Serialize, Deserialize)]
21
24
#[serde(rename_all = "camelCase")]
22
25
pub struct GetPostThreadV2Res {
23
23
-
pub thread: Vec<ThreadV2Item>,
26
26
+
pub thread: Vec<ThreadV2Item<'static>>,
24
27
#[serde(skip_serializing_if = "Option::is_none")]
25
25
-
pub threadgate: Option<ThreadgateView>,
28
28
+
pub threadgate: Option<ThreadgateView<'static>>,
26
29
pub has_other_replies: bool,
27
30
}
28
31
···
30
33
#[derive(Debug, Serialize, Deserialize)]
31
34
#[serde(rename_all = "camelCase")]
32
35
pub struct GetPostThreadOtherV2Res {
33
33
-
pub thread: Vec<ThreadV2Item>,
36
36
+
pub thread: Vec<ThreadV2Item<'static>>,
34
37
}
35
38
36
39
/// Represents a post with its hydrated view and parent/child relationships
37
40
#[expect(dead_code)]
38
41
#[derive(Debug)]
39
39
-
pub struct ThreadNode {
42
42
+
pub struct ThreadNode<'a> {
40
43
pub uri: String,
41
41
-
pub post_view: Option<PostView>,
44
44
+
pub post_view: Option<PostView<'a>>,
42
45
pub parent_uri: Option<String>,
43
46
pub depth: i32,
44
47
pub children: Vec<String>,
+29
-15
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs
···
14
14
use super::sorting::{sort_other_replies, sort_thread_items};
15
15
16
16
/// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels
17
17
-
fn post_to_thread_item_type(post: lexica::app_bsky::feed::PostView, is_authenticated: bool) -> ThreadV2ItemType {
17
17
+
fn post_to_thread_item_type(post: jacquard_api::app_bsky::feed::PostView, is_authenticated: bool) -> ThreadV2ItemType {
18
18
// Check if author is blocked by viewer or viewer is blocked by author
19
19
if let Some(viewer) = &post.author.viewer {
20
20
-
if viewer.blocked_by || viewer.blocking.is_some() {
21
21
-
return ThreadV2ItemType::Blocked {
22
22
-
author: Box::new(lexica::app_bsky::feed::BlockedAuthor {
23
23
-
did: post.author.did.clone(),
24
24
-
viewer: Some(viewer.clone()),
25
25
-
}),
26
26
-
};
20
20
+
if viewer.blocked_by.unwrap_or(false) || viewer.blocking.is_some() {
21
21
+
return ThreadV2ItemType::ThreadItemBlocked(Box::new(
22
22
+
jacquard_api::app_bsky::unspecced::ThreadItemBlocked {
23
23
+
author: jacquard_api::app_bsky::feed::BlockedAuthor {
24
24
+
did: post.author.did.clone(),
25
25
+
viewer: Some(viewer.clone()),
26
26
+
extra_data: None,
27
27
+
},
28
28
+
extra_data: None,
29
29
+
}
30
30
+
));
27
31
}
28
32
}
29
33
···
31
35
if !is_authenticated {
32
36
let has_no_unauth_label = post.author.labels.iter().any(|label| label.val == "!no-unauthenticated");
33
37
if has_no_unauth_label {
34
34
-
return ThreadV2ItemType::NoUnauthenticated {};
38
38
+
return ThreadV2ItemType::ThreadItemNoUnauthenticated(Box::new(
39
39
+
jacquard_api::app_bsky::unspecced::ThreadItemNoUnauthenticated { extra_data: None }
40
40
+
));
35
41
}
36
42
}
37
43
38
44
// Return normal post
39
39
-
ThreadV2ItemType::Post(Box::new(ThreadItemPost {
45
45
+
ThreadV2ItemType::ThreadItemPost(Box::new(ThreadItemPost {
40
46
post,
41
47
more_parents: false,
42
48
more_replies: 0,
···
156
162
for (reply_uri, _) in direct_replies {
157
163
if let Some(post_view) = replies_hydrated.get(&reply_uri) {
158
164
thread_items.push(ThreadV2Item {
159
159
-
uri: reply_uri.clone(),
165
165
+
uri: jacquard_common::types::aturi::AtUri::new(&reply_uri).unwrap(),
160
166
depth: 1, // All direct replies have depth 1
161
167
value: post_to_thread_item_type(post_view.clone(), is_authenticated),
168
168
+
extra_data: None,
162
169
});
163
170
} else {
164
171
// Post not found or couldn't be hydrated
165
172
thread_items.push(ThreadV2Item {
166
166
-
uri: reply_uri.clone(),
173
173
+
uri: jacquard_common::types::aturi::AtUri::new(&reply_uri).unwrap(),
167
174
depth: 1,
168
168
-
value: ThreadV2ItemType::NotFound {},
175
175
+
value: ThreadV2ItemType::ThreadItemNotFound(Box::new(
176
176
+
jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None }
177
177
+
)),
178
178
+
extra_data: None,
169
179
});
170
180
}
171
181
}
···
175
185
sort_thread_items(
176
186
&mut thread_items,
177
187
|item| &item.uri,
178
178
-
|item| item.depth,
188
188
+
|item| item.depth as i32,
179
189
|item| match &item.value {
180
180
-
ThreadV2ItemType::Post(post) => Some(post.post.indexed_at),
190
190
+
ThreadV2ItemType::ThreadItemPost(post) => {
191
191
+
// Convert jacquard Datetime to chrono DateTime
192
192
+
chrono::DateTime::parse_from_rfc3339(post.post.indexed_at.as_str()).ok()
193
193
+
.map(|dt| dt.with_timezone(&chrono::Utc))
194
194
+
},
181
195
_ => None,
182
196
},
183
197
);
+10
-4
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs
···
57
57
// Get the threadgate if available
58
58
let threadgate = anchor_post.threadgate.clone();
59
59
60
60
+
// Clone Arcs to extend lifetimes
61
61
+
let post_entity = state.post_entity.clone();
62
62
+
let profile_entity = state.profile_entity.clone();
63
63
+
let id_cache = state.id_cache.clone();
64
64
+
let pool = state.pool.clone();
65
65
+
60
66
// Create a thread builder
61
67
let builder = ThreadBuilder {
62
62
-
post_entity: &state.post_entity,
63
63
-
profile_entity: &state.profile_entity,
68
68
+
post_entity: &post_entity,
69
69
+
profile_entity: &profile_entity,
64
70
// post_cache: &state.post_cache, // TODO: Fix
65
71
anchor_uri: uri,
66
72
anchor_post,
···
71
77
sort: Some(query.sort),
72
78
prioritize_followed_users: Some(query.prioritize_followed_users),
73
79
is_authenticated,
74
74
-
id_cache: &state.id_cache,
75
75
-
pool: &state.pool,
80
80
+
id_cache: &id_cache,
81
81
+
pool: &pool,
76
82
};
77
83
78
84
// Build the thread
+1
-1
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/sorting.rs
···
1
1
use chrono::{DateTime, Utc};
2
2
-
use lexica::app_bsky::feed::PostView;
2
2
+
use jacquard_api::app_bsky::feed::PostView;
3
3
use std::cmp::Ordering;
4
4
use std::collections::HashMap;
5
5
+105
-36
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs
···
1
1
use diesel_async::pooled_connection::deadpool::Pool;
2
2
use diesel_async::AsyncPgConnection;
3
3
-
use lexica::app_bsky::feed::{PostView, ThreadgateView};
3
3
+
use jacquard_api::app_bsky::feed::{PostView, ThreadgateView};
4
4
use std::collections::HashMap;
5
5
use std::sync::Arc;
6
6
···
13
13
pub post_entity: &'a crate::entities::PostEntity,
14
14
pub profile_entity: &'a crate::entities::ProfileEntity,
15
15
pub anchor_uri: String,
16
16
-
pub anchor_post: PostView,
17
17
-
pub threadgate: Option<ThreadgateView>,
16
16
+
pub anchor_post: PostView<'static>,
17
17
+
pub threadgate: Option<ThreadgateView<'static>>,
18
18
pub above: bool,
19
19
pub below: i32,
20
20
pub branching_factor: i32,
···
27
27
28
28
impl ThreadBuilder<'_> {
29
29
/// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels
30
30
-
fn post_to_thread_item_type(&self, post: PostView, op_thread: bool) -> ThreadV2ItemType {
30
30
+
fn post_to_thread_item_type(&self, post: PostView<'static>, op_thread: bool) -> ThreadV2ItemType<'static> {
31
31
// Check if author is blocked by viewer or viewer is blocked by author
32
32
if let Some(viewer) = &post.author.viewer {
33
33
if viewer.blocked_by || viewer.blocking.is_some() {
34
34
return ThreadV2ItemType::Blocked {
35
35
-
author: Box::new(lexica::app_bsky::feed::BlockedAuthor {
35
35
+
author: Box::new(jacquard_api::app_bsky::feed::BlockedAuthor {
36
36
did: post.author.did.clone(),
37
37
viewer: Some(viewer.clone()),
38
38
+
extra_data: None,
38
39
}),
39
40
};
40
41
}
···
77
78
78
79
// Step 2: Add the anchor post at depth 0
79
80
thread_items.push(ThreadV2Item {
80
80
-
uri: self.anchor_post.uri.clone(),
81
81
+
uri: jacquard_common::types::aturi::AtUri::new(&self.anchor_post.uri).unwrap(),
81
82
depth: 0,
82
83
value: self.post_to_thread_item_type(self.anchor_post.clone(), true),
84
84
+
extra_data: None,
83
85
});
84
86
85
87
// Step 3: Add child posts if requested (below > 0)
···
102
104
async fn add_parent_posts(
103
105
&self,
104
106
conn: &mut diesel_async::AsyncPgConnection,
105
105
-
thread_items: &mut Vec<ThreadV2Item>,
107
107
+
thread_items: &mut Vec<ThreadV2Item<'_>>,
106
108
) -> crate::common::errors::XrpcResult<()> {
107
109
// Use actor_id-based query with IdCache
108
110
// NO FALLBACK - we require IdCache to be populated (should always be from hydrate_post)
109
111
let db_start = std::time::Instant::now();
110
112
111
113
// EARLY EXIT
112
112
-
let has_parent = self.anchor_post.record.get("reply")
113
113
-
.and_then(|reply| reply.get("parent"))
114
114
-
.is_some();
114
114
+
let has_parent = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record {
115
115
+
obj.get("reply")
116
116
+
.and_then(|reply| {
117
117
+
if let jacquard_common::Data::Object(ref reply_obj) = reply {
118
118
+
reply_obj.get("parent")
119
119
+
} else {
120
120
+
None
121
121
+
}
122
122
+
})
123
123
+
.is_some()
124
124
+
} else {
125
125
+
false
126
126
+
};
115
127
116
128
if !has_parent {
117
129
thread_items.extend(vec![]);
···
125
137
let anchor_rkey_base32 = parts[2];
126
138
127
139
// Extract root post from anchor's record (for filtering by thread)
128
128
-
let root_uri = self.anchor_post.record.get("reply")
129
129
-
.and_then(|reply| reply.get("root"))
130
130
-
.and_then(|root| root.get("uri"))
131
131
-
.and_then(|uri| uri.as_str())
132
132
-
.map(|s| s.to_string());
140
140
+
let root_uri = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record {
141
141
+
obj.get("reply")
142
142
+
.and_then(|reply| {
143
143
+
if let jacquard_common::Data::Object(ref reply_obj) = reply {
144
144
+
reply_obj.get("root")
145
145
+
} else {
146
146
+
None
147
147
+
}
148
148
+
})
149
149
+
.and_then(|root| {
150
150
+
if let jacquard_common::Data::Object(ref root_obj) = root {
151
151
+
root_obj.get("uri")
152
152
+
} else {
153
153
+
None
154
154
+
}
155
155
+
})
156
156
+
.and_then(|uri| {
157
157
+
if let jacquard_common::Data::String(ref s) = uri {
158
158
+
Some(s.as_str().to_string())
159
159
+
} else {
160
160
+
None
161
161
+
}
162
162
+
})
163
163
+
} else {
164
164
+
None
165
165
+
};
133
166
134
167
// Get actor_id from cache (should always be cached after hydrate_post)
135
168
let cached_actor = self.id_cache.get_actor_id(anchor_did).await
···
140
173
141
174
// Get root actor_id if we have a root URI
142
175
let root_info = if let Some(ref root_uri_str) = root_uri {
143
143
-
let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect();
176
176
+
let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect::<Vec<_>>();
144
177
if root_parts.len() >= 3 {
145
178
let root_did = root_parts[0];
146
179
let root_rkey_base32 = root_parts[2];
···
201
234
202
235
// Check if anchor post has a parent that wasn't returned by get_thread_parents
203
236
// (happens when parent post doesn't exist in our database - deleted or never indexed)
204
204
-
let anchor_parent_uri = self.anchor_post.record.get("reply")
205
205
-
.and_then(|reply| reply.get("parent"))
206
206
-
.and_then(|parent| parent.get("uri"))
207
207
-
.and_then(|uri| uri.as_str())
208
208
-
.map(|s| s.to_string());
237
237
+
let anchor_parent_uri = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record {
238
238
+
obj.get("reply")
239
239
+
.and_then(|reply| {
240
240
+
if let jacquard_common::Data::Object(ref reply_obj) = reply {
241
241
+
reply_obj.get("parent")
242
242
+
} else {
243
243
+
None
244
244
+
}
245
245
+
})
246
246
+
.and_then(|parent| {
247
247
+
if let jacquard_common::Data::Object(ref parent_obj) = parent {
248
248
+
parent_obj.get("uri")
249
249
+
} else {
250
250
+
None
251
251
+
}
252
252
+
})
253
253
+
.and_then(|uri| {
254
254
+
if let jacquard_common::Data::String(ref s) = uri {
255
255
+
Some(s.as_str().to_string())
256
256
+
} else {
257
257
+
None
258
258
+
}
259
259
+
})
260
260
+
} else {
261
261
+
None
262
262
+
};
209
263
210
264
if let Some(parent_uri) = &anchor_parent_uri {
211
265
// Check if this parent is in our parents list
···
214
268
// If not found and parents list is empty, create a tombstone for it
215
269
if !found_direct_parent && parents.is_empty() {
216
270
thread_items.push(ThreadV2Item {
217
217
-
uri: parent_uri.clone(),
271
271
+
uri: jacquard_common::types::aturi::AtUri::new(&parent_uri).unwrap(),
218
272
depth: -1, // Immediate parent at depth -1
219
219
-
value: ThreadV2ItemType::NotFound {},
273
273
+
value: ThreadV2ItemType::ThreadItemNotFound(Box::new(
274
274
+
jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None }
275
275
+
)),
276
276
+
extra_data: None,
220
277
});
221
278
}
222
279
}
···
227
284
228
285
if let Some(post_view) = parents_hydrated.get(parent_uri) {
229
286
thread_items.push(ThreadV2Item {
230
230
-
uri: parent_uri.clone(),
231
231
-
depth,
287
287
+
uri: jacquard_common::types::aturi::AtUri::new(parent_uri).unwrap(),
288
288
+
depth: depth as i64,
232
289
value: self.post_to_thread_item_type(post_view.clone(), true),
290
290
+
extra_data: None,
233
291
});
234
292
} else {
235
293
thread_items.push(ThreadV2Item {
236
236
-
uri: parent_uri.clone(),
237
237
-
depth,
238
238
-
value: ThreadV2ItemType::NotFound {},
294
294
+
uri: jacquard_common::types::aturi::AtUri::new(parent_uri).unwrap(),
295
295
+
depth: depth as i64,
296
296
+
value: ThreadV2ItemType::ThreadItemNotFound(Box::new(
297
297
+
jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None }
298
298
+
)),
299
299
+
extra_data: None,
239
300
});
240
301
}
241
302
}
···
247
308
async fn add_child_posts(
248
309
&self,
249
310
conn: &mut diesel_async::AsyncPgConnection,
250
250
-
thread_items: &mut Vec<ThreadV2Item>,
311
311
+
thread_items: &mut Vec<ThreadV2Item<'_>>,
251
312
) -> crate::common::errors::XrpcResult<()> {
252
313
// Get all replies from database with branching factor
253
314
// Query with branching_factor + 1 to know if there are more replies
···
343
404
for (child_uri, _sql_depth) in sorted_children {
344
405
if let Some(post_view) = replies_hydrated.get(&child_uri) {
345
406
thread_items.push(ThreadV2Item {
346
346
-
uri: child_uri.clone(),
407
407
+
extra_data: None,
408
408
+
uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(),
347
409
depth: 1, // Direct children are always depth 1
348
410
value: self.post_to_thread_item_type(post_view.clone(), false),
349
411
});
···
360
422
}
361
423
} else {
362
424
thread_items.push(ThreadV2Item {
363
363
-
uri: child_uri.clone(),
425
425
+
extra_data: None,
426
426
+
uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(),
364
427
depth: 1,
365
365
-
value: ThreadV2ItemType::NotFound {},
428
428
+
value: ThreadV2ItemType::ThreadItemNotFound(Box::new(
429
429
+
jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None }
430
430
+
)),
366
431
});
367
432
}
368
433
}
···
378
443
parent_depth: i32,
379
444
children_by_parent: &HashMap<String, Vec<(String, i32)>>,
380
445
replies_hydrated: &HashMap<String, PostView>,
381
381
-
thread_items: &mut Vec<ThreadV2Item>,
446
446
+
thread_items: &mut Vec<ThreadV2Item<'_>>,
382
447
) {
383
448
if let Some(children) = children_by_parent.get(parent_uri) {
384
449
// Sort children according to the requested sort mode
···
399
464
400
465
if let Some(post_view) = replies_hydrated.get(child_uri) {
401
466
thread_items.push(ThreadV2Item {
402
402
-
uri: child_uri.clone(),
467
467
+
extra_data: None,
468
468
+
uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(),
403
469
depth: child_depth,
404
470
value: self.post_to_thread_item_type(post_view.clone(), false),
405
471
});
···
416
482
}
417
483
} else {
418
484
thread_items.push(ThreadV2Item {
419
419
-
uri: child_uri.clone(),
485
485
+
extra_data: None,
486
486
+
uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(),
420
487
depth: child_depth,
421
421
-
value: ThreadV2ItemType::NotFound {},
488
488
+
value: ThreadV2ItemType::ThreadItemNotFound(Box::new(
489
489
+
jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None }
490
490
+
)),
422
491
});
423
492
}
424
493
}
+3
-2
parakeet/src/xrpc/community_lexicon/bookmarks.rs
···
4
4
use crate::GlobalState;
5
5
use axum::extract::{Query, State};
6
6
use axum::Json;
7
7
-
use lexica::community_lexicon::bookmarks::Bookmark;
7
7
+
use jacquard_api::community_lexicon::bookmarks::bookmark::Bookmark;
8
8
use serde::{Deserialize, Serialize};
9
9
10
10
#[derive(Debug, Deserialize)]
···
18
18
pub struct GetActorBookmarksRes {
19
19
#[serde(skip_serializing_if = "Option::is_none")]
20
20
cursor: Option<String>,
21
21
-
bookmarks: Vec<Bookmark>,
21
21
+
bookmarks: Vec<Bookmark<'static>>,
22
22
}
23
23
24
24
pub async fn get_actor_bookmarks(
···
101
101
subject: subject_uri,
102
102
tags: Vec::new(), // Tags not supported in current schema
103
103
created_at: bookmark.created_at(),
104
104
+
extra_data: None,
104
105
})
105
106
})
106
107
.collect();