BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: feed management commands and user preferences retrieval

+576 -18
+10 -10
docs/tasks/03-feeds.md
··· 6 6 7 7 ### Backend — `src-tauri/src/feed.rs` 8 8 9 - - [ ] `get_preferences()` — calls `app.bsky.actor.getPreferences`, extracts `savedFeedsPrefV2` items and `feedViewPref` entries 10 - - [ ] `get_feed_generators(uris: Vec<String>)` — calls `app.bsky.feed.getFeedGenerators` to hydrate display names/avatars 11 - - [ ] `get_timeline(cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getTimeline` 12 - - [ ] `get_feed(uri: String, cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getFeed` for custom feed generators 13 - - [ ] `get_list_feed(uri: String, cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getListFeed` 14 - - [ ] `get_post_thread(uri: String)` — thread view 15 - - [ ] `get_author_feed(did: String, cursor: Option<String>)` 16 - - [ ] `create_post(text: String, reply_to: Option<ReplyRef>, embed: Option<Embed>)` — with richtext facet detection via `jacquard::richtext` 17 - - [ ] `like_post(uri: String, cid: String)` / `unlike_post(uri: String)` 18 - - [ ] `repost(uri: String, cid: String)` / `unrepost(uri: String)` 9 + - [x] `get_preferences()` — calls `app.bsky.actor.getPreferences`, extracts `savedFeedsPrefV2` items and `feedViewPref` entries 10 + - [x] `get_feed_generators(uris: Vec<String>)` — calls `app.bsky.feed.getFeedGenerators` to hydrate display names/avatars 11 + - [x] `get_timeline(cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getTimeline` 12 + - [x] `get_feed(uri: String, cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getFeed` for custom feed generators 13 + - [x] `get_list_feed(uri: String, cursor: Option<String>, limit: u32)` — calls `app.bsky.feed.getListFeed` 14 + - [x] `get_post_thread(uri: String)` — thread view 15 + - [x] `get_author_feed(did: String, cursor: Option<String>)` 16 + - [x] `create_post(text: String, reply_to: Option<ReplyRef>, embed: Option<Embed>)` — with richtext facet detection via `jacquard::richtext` 17 + - [x] `like_post(uri: String, cid: String)` / `unlike_post(uri: String)` 18 + - [x] `repost(uri: String, cid: String)` / `unrepost(uri: String)` 19 19 20 20 ### Frontend — Feed Tabs & Content 21 21
+1
src-tauri/Cargo.toml
··· 71 71 len_zero = "allow" 72 72 range_plus_one = "allow" 73 73 manual_range_contains = "allow" 74 + result_large_err = "allow"
+70
src-tauri/src/commands.rs
··· 1 1 use super::error::AppError; 2 + use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, UserPreferences}; 2 3 use super::state::{AccountSummary, AppBootstrap, AppState}; 4 + use serde_json::Value; 3 5 use tauri::{AppHandle, State}; 4 6 5 7 #[tauri::command] ··· 31 33 pub async fn set_active_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 32 34 state.switch_account(&app, &did).await 33 35 } 36 + 37 + #[tauri::command] 38 + pub async fn get_preferences(state: State<'_, AppState>) -> Result<UserPreferences, AppError> { 39 + feed::get_preferences(&state).await 40 + } 41 + 42 + #[tauri::command] 43 + pub async fn get_feed_generators(uris: Vec<String>, state: State<'_, AppState>) -> Result<Value, AppError> { 44 + feed::get_feed_generators(uris, &state).await 45 + } 46 + 47 + #[tauri::command] 48 + pub async fn get_timeline(cursor: Option<String>, limit: u32, state: State<'_, AppState>) -> Result<Value, AppError> { 49 + feed::get_timeline(cursor, limit, &state).await 50 + } 51 + 52 + #[tauri::command] 53 + pub async fn get_feed( 54 + uri: String, cursor: Option<String>, limit: u32, state: State<'_, AppState>, 55 + ) -> Result<Value, AppError> { 56 + feed::get_feed(uri, cursor, limit, &state).await 57 + } 58 + 59 + #[tauri::command] 60 + pub async fn get_list_feed( 61 + uri: String, cursor: Option<String>, limit: u32, state: State<'_, AppState>, 62 + ) -> Result<Value, AppError> { 63 + feed::get_list_feed(uri, cursor, limit, &state).await 64 + } 65 + 66 + #[tauri::command] 67 + pub async fn get_post_thread(uri: String, state: State<'_, AppState>) -> Result<Value, AppError> { 68 + feed::get_post_thread(uri, &state).await 69 + } 70 + 71 + #[tauri::command] 72 + pub async fn get_author_feed( 73 + did: String, cursor: Option<String>, state: State<'_, AppState>, 74 + ) -> Result<Value, AppError> { 75 + feed::get_author_feed(did, cursor, &state).await 76 + } 77 + 78 + #[tauri::command] 79 + pub async fn create_post( 80 + text: String, reply_to: Option<ReplyRefInput>, embed: Option<EmbedInput>, state: State<'_, AppState>, 81 + ) -> Result<CreateRecordResult, AppError> { 82 + feed::create_post(text, reply_to, embed, &state).await 83 + } 84 + 85 + #[tauri::command] 86 + pub async fn like_post(uri: String, cid: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 87 + feed::like_post(uri, cid, &state).await 88 + } 89 + 90 + #[tauri::command] 91 + pub async fn unlike_post(like_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 92 + feed::unlike_post(like_uri, &state).await 93 + } 94 + 95 + #[tauri::command] 96 + pub async fn repost(uri: String, cid: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 97 + feed::repost(uri, cid, &state).await 98 + } 99 + 100 + #[tauri::command] 101 + pub async fn unrepost(repost_uri: String, state: State<'_, AppState>) -> Result<(), AppError> { 102 + feed::unrepost(repost_uri, &state).await 103 + }
+9 -1
src-tauri/src/error.rs
··· 41 41 } 42 42 43 43 impl serde::Serialize for AppError { 44 - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 44 + fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> 45 45 where 46 46 S: serde::ser::Serializer, 47 47 { 48 48 serializer.serialize_str(&self.to_string()) 49 49 } 50 50 } 51 + 52 + impl AppError { 53 + pub fn validation(msg: impl Into<String>) -> Self { 54 + AppError::Validation(msg.into()) 55 + } 56 + } 57 + 58 + pub type Result<T> = std::result::Result<T, AppError>;
+466
src-tauri/src/feed.rs
··· 1 + use super::auth::LazuriteOAuthSession; 2 + use super::error::{AppError, Result}; 3 + use super::state::AppState; 4 + use jacquard::api::app_bsky::actor::get_preferences::GetPreferences; 5 + use jacquard::api::app_bsky::actor::{FeedViewPref, PreferencesItem, SavedFeedType, SavedFeedsPrefV2}; 6 + use jacquard::api::app_bsky::embed::record::Record; 7 + use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 8 + use jacquard::api::app_bsky::feed::get_feed::GetFeed; 9 + use jacquard::api::app_bsky::feed::get_feed_generators::GetFeedGenerators; 10 + use jacquard::api::app_bsky::feed::get_list_feed::GetListFeed; 11 + use jacquard::api::app_bsky::feed::get_post_thread::GetPostThread; 12 + use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 13 + use jacquard::api::app_bsky::feed::like::Like; 14 + use jacquard::api::app_bsky::feed::post::{Post, PostEmbed, ReplyRef}; 15 + use jacquard::api::app_bsky::feed::repost::Repost; 16 + use jacquard::api::com_atproto::repo::create_record::CreateRecord; 17 + use jacquard::api::com_atproto::repo::delete_record::DeleteRecord; 18 + use jacquard::api::com_atproto::repo::strong_ref::StrongRef; 19 + use jacquard::identity::JacquardResolver; 20 + use jacquard::richtext; 21 + use jacquard::types::aturi::AtUri; 22 + use jacquard::types::cid::Cid; 23 + use jacquard::types::datetime::Datetime; 24 + use jacquard::types::did::Did; 25 + use jacquard::types::ident::AtIdentifier; 26 + use jacquard::types::nsid::Nsid; 27 + use jacquard::types::recordkey::RecordKey; 28 + use jacquard::types::value::Data; 29 + use jacquard::xrpc::XrpcClient; 30 + use jacquard::IntoStatic; 31 + use serde::{Deserialize, Serialize}; 32 + use std::sync::Arc; 33 + 34 + async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 35 + let did = state 36 + .active_session 37 + .read() 38 + .map_err(|_| AppError::StatePoisoned("active_session"))? 39 + .as_ref() 40 + .ok_or_else(|| AppError::Validation("no active account".into()))? 41 + .did 42 + .clone(); 43 + 44 + state 45 + .sessions 46 + .read() 47 + .map_err(|_| AppError::StatePoisoned("sessions"))? 48 + .get(&did) 49 + .cloned() 50 + .ok_or_else(|| AppError::Validation("session not found for active account".into())) 51 + } 52 + 53 + fn active_did(state: &AppState) -> Result<String> { 54 + state 55 + .active_session 56 + .read() 57 + .map_err(|_| AppError::StatePoisoned("active_session"))? 58 + .as_ref() 59 + .ok_or_else(|| AppError::Validation("no active account".into())) 60 + .map(|s| s.did.clone()) 61 + } 62 + 63 + #[derive(Debug, Serialize, Deserialize)] 64 + #[serde(rename_all = "camelCase")] 65 + pub struct SavedFeedItem { 66 + pub id: String, 67 + pub r#type: String, 68 + pub value: String, 69 + pub pinned: bool, 70 + } 71 + 72 + #[derive(Debug, Serialize, Deserialize)] 73 + #[serde(rename_all = "camelCase")] 74 + pub struct FeedViewPrefItem { 75 + pub feed: String, 76 + pub hide_replies: bool, 77 + pub hide_replies_by_unfollowed: bool, 78 + pub hide_replies_by_like_count: Option<i64>, 79 + pub hide_reposts: bool, 80 + pub hide_quote_posts: bool, 81 + } 82 + 83 + #[derive(Debug, Serialize, Deserialize)] 84 + #[serde(rename_all = "camelCase")] 85 + pub struct UserPreferences { 86 + pub saved_feeds: Vec<SavedFeedItem>, 87 + pub feed_view_prefs: Vec<FeedViewPrefItem>, 88 + } 89 + 90 + fn extract_saved_feeds(pref: &SavedFeedsPrefV2<'_>) -> Vec<SavedFeedItem> { 91 + pref.items 92 + .iter() 93 + .map(|f| SavedFeedItem { 94 + id: f.id.to_string(), 95 + r#type: match &f.r#type { 96 + SavedFeedType::Timeline => "timeline".into(), 97 + SavedFeedType::Feed => "feed".into(), 98 + SavedFeedType::List => "list".into(), 99 + SavedFeedType::Other(s) => s.to_string(), 100 + }, 101 + value: f.value.to_string(), 102 + pinned: f.pinned, 103 + }) 104 + .collect() 105 + } 106 + 107 + fn extract_feed_view_pref(pref: &FeedViewPref<'_>) -> FeedViewPrefItem { 108 + FeedViewPrefItem { 109 + feed: pref.feed.to_string(), 110 + hide_replies: pref.hide_replies.unwrap_or(false), 111 + hide_replies_by_unfollowed: pref.hide_replies_by_unfollowed.unwrap_or(true), 112 + hide_replies_by_like_count: pref.hide_replies_by_like_count, 113 + hide_reposts: pref.hide_reposts.unwrap_or(false), 114 + hide_quote_posts: pref.hide_quote_posts.unwrap_or(false), 115 + } 116 + } 117 + 118 + #[derive(Debug, Deserialize)] 119 + #[serde(rename_all = "camelCase")] 120 + pub struct StrongRefInput { 121 + pub uri: String, 122 + pub cid: String, 123 + } 124 + 125 + #[derive(Debug, Deserialize)] 126 + #[serde(rename_all = "camelCase")] 127 + pub struct ReplyRefInput { 128 + pub parent: StrongRefInput, 129 + pub root: StrongRefInput, 130 + } 131 + 132 + #[derive(Debug, Deserialize)] 133 + #[serde(tag = "type", rename_all = "camelCase")] 134 + pub enum EmbedInput { 135 + Record { record: StrongRefInput }, 136 + } 137 + 138 + #[derive(Debug, Deserialize, Serialize)] 139 + #[serde(rename_all = "camelCase")] 140 + pub struct CreateRecordResult { 141 + pub uri: String, 142 + pub cid: String, 143 + } 144 + 145 + pub async fn get_preferences(state: &AppState) -> Result<UserPreferences> { 146 + let session = get_session(state).await?; 147 + let output = session 148 + .send(GetPreferences) 149 + .await 150 + .map_err(|_| AppError::validation("getPreferences"))? 151 + .into_output() 152 + .map_err(|_| AppError::validation("getPreferences output"))?; 153 + 154 + let mut saved_feeds = Vec::new(); 155 + let mut feed_view_prefs = Vec::new(); 156 + 157 + for item in &output.preferences { 158 + match item { 159 + PreferencesItem::SavedFeedsPrefV2(pref) => { 160 + saved_feeds = extract_saved_feeds(pref); 161 + } 162 + PreferencesItem::FeedViewPref(pref) => { 163 + feed_view_prefs.push(extract_feed_view_pref(pref)); 164 + } 165 + _ => {} 166 + } 167 + } 168 + 169 + Ok(UserPreferences { saved_feeds, feed_view_prefs }) 170 + } 171 + 172 + pub async fn get_feed_generators(uris: Vec<String>, state: &AppState) -> Result<serde_json::Value> { 173 + if uris.is_empty() { 174 + return Ok(serde_json::json!({ "feeds": [] })); 175 + } 176 + 177 + let session = get_session(state).await?; 178 + let parsed: std::result::Result<Vec<AtUri<'_>>, _> = uris.iter().map(|u| AtUri::new(u)).collect(); 179 + let feeds = parsed.map_err(|_| AppError::validation("invalid feed URI"))?; 180 + 181 + let output = session 182 + .send(GetFeedGenerators::new().feeds(feeds).build()) 183 + .await 184 + .map_err(|_| AppError::validation("getFeedGenerators"))? 185 + .into_output() 186 + .map_err(|_| AppError::validation("getFeedGenerators output"))?; 187 + 188 + serde_json::to_value(&output).map_err(AppError::from) 189 + } 190 + 191 + pub async fn get_timeline(cursor: Option<String>, limit: u32, state: &AppState) -> Result<serde_json::Value> { 192 + let session = get_session(state).await?; 193 + let mut req = GetTimeline::new().limit(limit as i64); 194 + if let Some(c) = &cursor { 195 + req = req.cursor(Some(c.into())); 196 + } 197 + 198 + let output = session 199 + .send(req.build()) 200 + .await 201 + .map_err(|_| AppError::validation("getTimeline"))? 202 + .into_output() 203 + .map_err(|_| AppError::validation("getTimeline output"))?; 204 + 205 + serde_json::to_value(&output).map_err(AppError::from) 206 + } 207 + 208 + pub async fn get_feed(uri: String, cursor: Option<String>, limit: u32, state: &AppState) -> Result<serde_json::Value> { 209 + let session = get_session(state).await?; 210 + let feed_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid feed URI"))?; 211 + let mut req = GetFeed::new().feed(feed_uri).limit(limit as i64); 212 + if let Some(c) = &cursor { 213 + req = req.cursor(Some(c.into())); 214 + } 215 + 216 + let output = session 217 + .send(req.build()) 218 + .await 219 + .map_err(|_| AppError::validation("getFeed"))? 220 + .into_output() 221 + .map_err(|_| AppError::validation("getFeed output"))?; 222 + 223 + serde_json::to_value(&output).map_err(AppError::from) 224 + } 225 + 226 + pub async fn get_list_feed( 227 + uri: String, cursor: Option<String>, limit: u32, state: &AppState, 228 + ) -> Result<serde_json::Value> { 229 + let session = get_session(state).await?; 230 + let list_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid list URI"))?; 231 + let mut req = GetListFeed::new().list(list_uri).limit(limit as i64); 232 + if let Some(c) = &cursor { 233 + req = req.cursor(Some(c.into())); 234 + } 235 + 236 + let output = session 237 + .send(req.build()) 238 + .await 239 + .map_err(|_| AppError::validation("getListFeed"))? 240 + .into_output() 241 + .map_err(|_| AppError::validation("getListFeed output"))?; 242 + 243 + serde_json::to_value(&output).map_err(AppError::from) 244 + } 245 + 246 + pub async fn get_post_thread(uri: String, state: &AppState) -> Result<serde_json::Value> { 247 + let session = get_session(state).await?; 248 + let post_uri = AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?; 249 + 250 + let output = session 251 + .send(GetPostThread::new().uri(post_uri).build()) 252 + .await 253 + .map_err(|_| AppError::validation("getPostThread"))? 254 + .into_output() 255 + .map_err(|_| AppError::validation("getPostThread output"))?; 256 + 257 + serde_json::to_value(&output).map_err(AppError::from) 258 + } 259 + 260 + pub async fn get_author_feed(did: String, cursor: Option<String>, state: &AppState) -> Result<serde_json::Value> { 261 + let session = get_session(state).await?; 262 + let actor = AtIdentifier::Did(Did::new(&did)?); 263 + let mut req = GetAuthorFeed::new().actor(actor); 264 + if let Some(c) = &cursor { 265 + req = req.cursor(Some(c.as_str().into())); 266 + } 267 + 268 + let output = session 269 + .send(req.build()) 270 + .await 271 + .map_err(|_| AppError::validation("getAuthorFeed"))? 272 + .into_output() 273 + .map_err(|_| AppError::validation("getAuthorFeed output"))?; 274 + 275 + serde_json::to_value(&output).map_err(AppError::from) 276 + } 277 + 278 + pub async fn create_post( 279 + text: String, reply_to: Option<ReplyRefInput>, embed: Option<EmbedInput>, state: &AppState, 280 + ) -> Result<CreateRecordResult> { 281 + if text.trim().is_empty() && embed.is_none() { 282 + return Err(AppError::validation("post requires text or embed")); 283 + } 284 + 285 + let session = get_session(state).await?; 286 + let did = active_did(state)?; 287 + 288 + let resolver = JacquardResolver::default(); 289 + let rich = richtext::parse(&text) 290 + .build_async(&resolver) 291 + .await 292 + .map_err(|_| AppError::validation("richtext parse"))?; 293 + 294 + let mut post = Post::new().text(rich.text).created_at(Datetime::now()); 295 + 296 + if let Some(facets) = rich.facets { 297 + post = post.facets(facets); 298 + } 299 + 300 + if let Some(reply) = reply_to { 301 + let reply_ref = ReplyRef::new() 302 + .parent(strong_ref_from_input(&reply.parent)?) 303 + .root(strong_ref_from_input(&reply.root)?) 304 + .build(); 305 + post = post.reply(reply_ref); 306 + } 307 + 308 + if let Some(embed) = embed { 309 + post = post.embed(post_embed_from_input(embed)?); 310 + } 311 + 312 + let record_json = serde_json::to_value(post.build())?; 313 + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize post"))?; 314 + 315 + let repo = AtIdentifier::Did(Did::new(&did)?); 316 + let collection = Nsid::new("app.bsky.feed.post").map_err(|_| AppError::validation("nsid"))?; 317 + 318 + let output = session 319 + .send( 320 + CreateRecord::new() 321 + .repo(repo) 322 + .collection(collection) 323 + .record(record_data) 324 + .build(), 325 + ) 326 + .await 327 + .map_err(|_| AppError::validation("createRecord (post)"))? 328 + .into_output() 329 + .map_err(|_| AppError::validation("createRecord (post) output"))?; 330 + 331 + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 332 + } 333 + 334 + pub async fn like_post(uri: String, cid: String, state: &AppState) -> Result<CreateRecordResult> { 335 + let session = get_session(state).await?; 336 + let did = active_did(state)?; 337 + 338 + let subject = StrongRef::new() 339 + .uri(AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?) 340 + .cid(Cid::str(&cid)) 341 + .build(); 342 + 343 + let like = Like::new().created_at(Datetime::now()).subject(subject).build(); 344 + 345 + let record_json = serde_json::to_value(&like)?; 346 + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize like"))?; 347 + 348 + let repo = AtIdentifier::Did(Did::new(&did)?); 349 + let collection = Nsid::new("app.bsky.feed.like").map_err(|_| AppError::validation("nsid"))?; 350 + 351 + let output = session 352 + .send( 353 + CreateRecord::new() 354 + .repo(repo) 355 + .collection(collection) 356 + .record(record_data) 357 + .build(), 358 + ) 359 + .await 360 + .map_err(|_| AppError::validation("createRecord (like)"))? 361 + .into_output() 362 + .map_err(|_| AppError::validation("createRecord (like) output"))?; 363 + 364 + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 365 + } 366 + 367 + pub async fn unlike_post(like_uri: String, state: &AppState) -> Result<()> { 368 + let session = get_session(state).await?; 369 + let did = active_did(state)?; 370 + 371 + let at_uri = AtUri::new(&like_uri).map_err(|_| AppError::validation("invalid like URI"))?; 372 + let RecordKey(rkey) = at_uri 373 + .rkey() 374 + .ok_or_else(|| AppError::Validation("like URI has no rkey".into()))?; 375 + let rkey_str = rkey.to_string(); 376 + 377 + let repo = AtIdentifier::Did(Did::new(&did)?); 378 + let collection = Nsid::new("app.bsky.feed.like").map_err(|_| AppError::validation("nsid"))?; 379 + let rkey = RecordKey::any(&rkey_str).map_err(|_| AppError::validation("invalid rkey"))?; 380 + 381 + session 382 + .send(DeleteRecord::new().repo(repo).collection(collection).rkey(rkey).build()) 383 + .await 384 + .map_err(|_| AppError::validation("deleteRecord (unlike)"))? 385 + .into_output() 386 + .map_err(|_| AppError::validation("deleteRecord (unlike) output"))?; 387 + 388 + Ok(()) 389 + } 390 + 391 + pub async fn repost(uri: String, cid: String, state: &AppState) -> Result<CreateRecordResult> { 392 + let session = get_session(state).await?; 393 + let did = active_did(state)?; 394 + 395 + let subject = StrongRef::new() 396 + .uri(AtUri::new(&uri).map_err(|_| AppError::validation("invalid post URI"))?) 397 + .cid(Cid::str(&cid)) 398 + .build(); 399 + 400 + let repost = Repost::new().created_at(Datetime::now()).subject(subject).build(); 401 + 402 + let record_json = serde_json::to_value(&repost)?; 403 + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize repost"))?; 404 + 405 + let repo = AtIdentifier::Did(Did::new(&did)?); 406 + let collection = Nsid::new("app.bsky.feed.repost").map_err(|_| AppError::validation("nsid"))?; 407 + 408 + let output = session 409 + .send( 410 + CreateRecord::new() 411 + .repo(repo) 412 + .collection(collection) 413 + .record(record_data) 414 + .build(), 415 + ) 416 + .await 417 + .map_err(|_| AppError::validation("createRecord (repost)"))? 418 + .into_output() 419 + .map_err(|_| AppError::validation("createRecord (repost) output"))?; 420 + 421 + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 422 + } 423 + 424 + pub async fn unrepost(repost_uri: String, state: &AppState) -> Result<()> { 425 + let session = get_session(state).await?; 426 + let did = active_did(state)?; 427 + 428 + let at_uri = AtUri::new(&repost_uri).map_err(|_| AppError::validation("invalid repost URI"))?; 429 + let RecordKey(rkey) = at_uri 430 + .rkey() 431 + .ok_or_else(|| AppError::Validation("repost URI has no rkey".into()))?; 432 + 433 + let rkey_str = rkey.to_string(); 434 + 435 + let repo = AtIdentifier::Did(Did::new(&did)?); 436 + let collection = Nsid::new("app.bsky.feed.repost").map_err(|_| AppError::validation("nsid"))?; 437 + let rkey = RecordKey::any(&rkey_str).map_err(|_| AppError::validation("invalid rkey"))?; 438 + 439 + session 440 + .send(DeleteRecord::new().repo(repo).collection(collection).rkey(rkey).build()) 441 + .await 442 + .map_err(|_| AppError::validation("deleteRecord (unrepost)"))? 443 + .into_output() 444 + .map_err(|_| AppError::validation("deleteRecord (unrepost) output"))?; 445 + 446 + Ok(()) 447 + } 448 + 449 + fn strong_ref_from_input(input: &StrongRefInput) -> Result<StrongRef<'static>> { 450 + Ok(StrongRef::new() 451 + .uri( 452 + AtUri::new(&input.uri) 453 + .map_err(|_| AppError::validation("invalid URI in StrongRef"))? 454 + .into_static(), 455 + ) 456 + .cid(Cid::from(input.cid.clone()).into_static()) 457 + .build()) 458 + } 459 + 460 + fn post_embed_from_input(input: EmbedInput) -> Result<PostEmbed<'static>> { 461 + match input { 462 + EmbedInput::Record { record } => Ok(PostEmbed::Record(Box::new( 463 + Record::new().record(strong_ref_from_input(&record)?).build(), 464 + ))), 465 + } 466 + }
+20 -7
src-tauri/src/lib.rs
··· 2 2 mod commands; 3 3 mod db; 4 4 mod error; 5 + mod feed; 5 6 mod state; 6 7 7 8 use auth::emit_at_uri_navigation; 8 - use commands::{get_app_bootstrap, list_accounts, login, logout, set_active_account, switch_account}; 9 + use commands as cmd; 9 10 use db::initialize_database; 10 11 use state::AppState; 11 12 use tauri::Manager; ··· 46 47 .plugin(tauri_plugin_deep_link::init()) 47 48 .plugin(tauri_plugin_opener::init()) 48 49 .invoke_handler(tauri::generate_handler![ 49 - get_app_bootstrap, 50 - list_accounts, 51 - login, 52 - logout, 53 - switch_account, 54 - set_active_account 50 + cmd::get_app_bootstrap, 51 + cmd::list_accounts, 52 + cmd::login, 53 + cmd::logout, 54 + cmd::switch_account, 55 + cmd::set_active_account, 56 + cmd::get_preferences, 57 + cmd::get_feed_generators, 58 + cmd::get_timeline, 59 + cmd::get_feed, 60 + cmd::get_list_feed, 61 + cmd::get_post_thread, 62 + cmd::get_author_feed, 63 + cmd::create_post, 64 + cmd::like_post, 65 + cmd::unlike_post, 66 + cmd::repost, 67 + cmd::unrepost 55 68 ]) 56 69 .run(tauri::generate_context!()) 57 70 .expect("error while running tauri application");