Undisclosed project number 1234
0

Configure Feed

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

feat(superjam): stars/follows/followers/reactions list commands

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (May 22, 2026, 11:25 PM +0300) commit 5ea60a19 parent b16114ba change-id qmpsvrtu
+827 -8
+33
crates/superjam-appview/src/queries/social.rs
··· 94 94 vec![("subject", actor.to_string())], 95 95 ) 96 96 } 97 + 98 + pub fn paginate_stars<'a>( 99 + &'a self, 100 + repo_did: &Did<DefaultStr>, 101 + ) -> Paginator<'a, RecordView<Star>> { 102 + Paginator::new( 103 + self, 104 + "sh.tangled.feed.listStars", 105 + vec![("subject", repo_did.to_string())], 106 + ) 107 + } 108 + 109 + pub fn paginate_follows<'a>( 110 + &'a self, 111 + user: &Did<DefaultStr>, 112 + ) -> Paginator<'a, RecordView<Follow>> { 113 + Paginator::new( 114 + self, 115 + "sh.tangled.graph.listFollows", 116 + vec![("subject", user.to_string())], 117 + ) 118 + } 119 + 120 + pub fn paginate_reactions<'a>( 121 + &'a self, 122 + subject: &AtUri<DefaultStr>, 123 + ) -> Paginator<'a, RecordView<Reaction>> { 124 + Paginator::new( 125 + self, 126 + "sh.tangled.feed.listReactions", 127 + vec![("subject", subject.to_string())], 128 + ) 129 + } 97 130 }
+69
crates/superjam/src/cli.rs
··· 86 86 React(ReactArgs), 87 87 #[command(about = "Remove a reaction")] 88 88 Unreact(ReactArgs), 89 + #[command(about = "Star listings")] 90 + Stars(StarsCmd), 91 + #[command(about = "Follow listings")] 92 + Follows(FollowsCmd), 93 + #[command(about = "List followers of a user")] 94 + Followers(FollowersArgs), 95 + #[command(about = "Reaction listings")] 96 + Reactions(ReactionsCmd), 89 97 #[command(about = "Profile view")] 90 98 Profile(ProfileCmd), 91 99 #[command(about = "Config inspection")] ··· 272 280 pub at_uri: AtUri<DefaultStr>, 273 281 #[arg(value_name = "EMOJI")] 274 282 pub emoji: Emoji, 283 + } 284 + 285 + #[derive(Args, Debug)] 286 + pub struct StarsCmd { 287 + #[command(subcommand)] 288 + pub action: StarsAction, 289 + } 290 + 291 + #[derive(Subcommand, Debug)] 292 + pub enum StarsAction { 293 + #[command(about = "Stars created by a user")] 294 + List { 295 + #[arg(value_name = "USER")] 296 + user: Option<AtIdentifier<DefaultStr>>, 297 + }, 298 + #[command(about = "Stars received by a repo")] 299 + On { 300 + #[arg(value_name = "REPO")] 301 + repo: Option<RepoRef>, 302 + }, 303 + } 304 + 305 + #[derive(Args, Debug)] 306 + pub struct FollowsCmd { 307 + #[command(subcommand)] 308 + pub action: FollowsAction, 309 + } 310 + 311 + #[derive(Subcommand, Debug)] 312 + pub enum FollowsAction { 313 + #[command(about = "Users that a user follows")] 314 + List { 315 + #[arg(value_name = "USER")] 316 + user: Option<AtIdentifier<DefaultStr>>, 317 + }, 318 + } 319 + 320 + #[derive(Args, Debug)] 321 + pub struct FollowersArgs { 322 + #[arg(value_name = "USER")] 323 + pub user: AtIdentifier<DefaultStr>, 324 + } 325 + 326 + #[derive(Args, Debug)] 327 + pub struct ReactionsCmd { 328 + #[command(subcommand)] 329 + pub action: ReactionsAction, 330 + } 331 + 332 + #[derive(Subcommand, Debug)] 333 + pub enum ReactionsAction { 334 + #[command(about = "Reactions created by a user")] 335 + List { 336 + #[arg(value_name = "USER")] 337 + user: Option<AtIdentifier<DefaultStr>>, 338 + }, 339 + #[command(about = "Reactions received by an at-uri")] 340 + On { 341 + #[arg(value_name = "AT-URI")] 342 + at_uri: AtUri<DefaultStr>, 343 + }, 275 344 } 276 345 277 346 #[derive(Args, Debug)]
+97 -6
crates/superjam/src/cmd/handles.rs
··· 31 31 AtIdentifier::Handle(_) => None, 32 32 }; 33 33 let Some(did) = did else { return }; 34 - if handles.contains_key(&did) { 34 + resolve_did_handle(appview, &did, handles).await; 35 + } 36 + 37 + pub async fn resolve_did_handle( 38 + appview: &AppviewClient, 39 + did: &Did, 40 + handles: &mut HashMap<Did, Handle<DefaultStr>>, 41 + ) { 42 + if handles.contains_key(did) { 35 43 return; 36 44 } 37 45 let ident = AtIdentifier::Did(did.clone()); ··· 40 48 handle: Some(handle), 41 49 .. 42 50 }) => { 43 - handles.insert(did, handle); 51 + handles.insert(did.clone(), handle); 44 52 } 45 53 Ok(_) => {} 46 54 Err(e) => { ··· 49 57 } 50 58 } 51 59 60 + pub fn did_label(did: &Did, handles: &HashMap<Did, Handle<DefaultStr>>) -> String { 61 + handles 62 + .get(did) 63 + .map(|h| h.as_str().to_owned()) 64 + .unwrap_or_else(|| did.as_str().to_owned()) 65 + } 66 + 67 + pub fn display_uri(uri: &AtUri<DefaultStr>, handles: &HashMap<Did, Handle<DefaultStr>>) -> String { 68 + let AtIdentifier::Did(d) = uri.authority() else { 69 + return uri.to_string(); 70 + }; 71 + let Ok(owned) = Did::new_owned(d.as_str()) else { 72 + return uri.to_string(); 73 + }; 74 + let Some(handle) = handles.get(&owned) else { 75 + return uri.to_string(); 76 + }; 77 + let raw = uri.to_string(); 78 + let prefix = format!("at://{}", d.as_str()); 79 + match raw.strip_prefix(&prefix) { 80 + Some(rest) => format!("at://{}{}", handle.as_str(), rest), 81 + None => raw, 82 + } 83 + } 84 + 52 85 pub async fn resolve_handles_for_uris<'a, I>( 53 86 appview: &AppviewClient, 54 87 uris: I, ··· 56 89 where 57 90 I: IntoIterator<Item = &'a AtUri<DefaultStr>>, 58 91 { 59 - let dids: Vec<Did> = uris 92 + let dids: HashSet<Did> = uris 60 93 .into_iter() 61 94 .filter_map(|u| match u.authority() { 62 95 AtIdentifier::Did(d) => Did::new_owned(d.as_str()).ok(), 63 96 AtIdentifier::Handle(_) => None, 64 97 }) 65 - .collect::<HashSet<_>>() 66 - .into_iter() 67 98 .collect(); 68 - let lookups = dids.into_iter().map(|did| async move { 99 + resolve_handles_for_dids(appview, dids.iter()).await 100 + } 101 + 102 + pub async fn resolve_handles_for_dids<'a, I>( 103 + appview: &AppviewClient, 104 + dids: I, 105 + ) -> HashMap<Did, Handle<DefaultStr>> 106 + where 107 + I: IntoIterator<Item = &'a Did>, 108 + { 109 + let unique: HashSet<Did> = dids.into_iter().cloned().collect(); 110 + let lookups = unique.into_iter().map(|did| async move { 69 111 let ident = AtIdentifier::Did(did.clone()); 70 112 let handle = match appview.resolve_mini_doc(&ident).await { 71 113 Ok(MiniDoc { handle, .. }) => handle, ··· 113 155 Handle::new_owned("nel.pet").unwrap(), 114 156 ); 115 157 assert_eq!(author_label(&uri, &handles), "nel.pet"); 158 + } 159 + 160 + #[test] 161 + fn display_uri_rewrites_did_authority_to_handle() { 162 + let uri = AtUri::<DefaultStr>::from_parts_owned( 163 + "did:plc:nel", 164 + "sh.tangled.repo.issue", 165 + "3lqj1aaaaaaaa", 166 + ) 167 + .unwrap(); 168 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 169 + handles.insert( 170 + Did::new_owned("did:plc:nel").unwrap(), 171 + Handle::new_owned("nel.pet").unwrap(), 172 + ); 173 + assert_eq!( 174 + display_uri(&uri, &handles), 175 + "at://nel.pet/sh.tangled.repo.issue/3lqj1aaaaaaaa", 176 + ); 177 + } 178 + 179 + #[test] 180 + fn display_uri_falls_back_to_did_when_handle_missing() { 181 + let uri = AtUri::<DefaultStr>::from_parts_owned( 182 + "did:plc:nel", 183 + "sh.tangled.repo.issue", 184 + "3lqj1aaaaaaaa", 185 + ) 186 + .unwrap(); 187 + let handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 188 + assert_eq!( 189 + display_uri(&uri, &handles), 190 + "at://did:plc:nel/sh.tangled.repo.issue/3lqj1aaaaaaaa", 191 + ); 192 + } 193 + 194 + #[test] 195 + fn display_uri_preserves_handle_authority() { 196 + let uri = AtUri::<DefaultStr>::from_parts_owned( 197 + "nel.pet", 198 + "sh.tangled.repo.issue", 199 + "3lqj1aaaaaaaa", 200 + ) 201 + .unwrap(); 202 + let handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 203 + assert_eq!( 204 + display_uri(&uri, &handles), 205 + "at://nel.pet/sh.tangled.repo.issue/3lqj1aaaaaaaa", 206 + ); 116 207 } 117 208 }
+1
crates/superjam/src/cmd/mod.rs
··· 10 10 pub mod session; 11 11 pub mod social; 12 12 pub mod social_cache; 13 + pub mod social_list;
+584
crates/superjam/src/cmd/social_list.rs
··· 1 + use std::collections::HashMap; 2 + use std::io::{self, Write}; 3 + 4 + use futures::stream::TryStreamExt; 5 + use jacquard_common::DefaultStr; 6 + use jacquard_common::types::aturi::AtUri; 7 + use jacquard_common::types::handle::Handle; 8 + use superjam_appview::{Error as AppviewError, RecordView}; 9 + use superjam_core::{AtIdentifier, Did, RepoRef}; 10 + use superjam_lexicon::sh_tangled::feed::reaction::Reaction; 11 + use superjam_lexicon::sh_tangled::feed::star::{Star, StarSubject}; 12 + use superjam_lexicon::sh_tangled::graph::follow::Follow; 13 + use superjam_oauth::Error as OauthError; 14 + use superjam_render::{ 15 + DEFAULT_LIST_CAP, FOLLOW_LIST_COLUMNS, FollowListRow, ListSink, Mode, REACTION_LIST_COLUMNS, 16 + ReactionListRow, STAR_LIST_COLUMNS, STAR_ON_REPO_COLUMNS, StarListRow, StarSubjectKind, 17 + follow_list_row_cells, reaction_list_row_cells, star_list_row_cells, star_on_repo_row_cells, 18 + }; 19 + use thiserror::Error; 20 + 21 + use crate::cmd::at_helpers::{RkeyFromUriError, rkey_from_uri}; 22 + use crate::cmd::handles::{ 23 + author_label, did_label, display_uri, resolve_author_handle, resolve_did_handle, 24 + }; 25 + use crate::cmd::repo_locator::{self, LocateError, RepoLocator}; 26 + use crate::cmd::session::{SessionCtx, SessionError, session_exit_kind}; 27 + 28 + #[derive(Debug)] 29 + pub enum SocialListAction { 30 + StarsList(Option<AtIdentifier<DefaultStr>>), 31 + StarsOn(RepoRef), 32 + FollowsList(Option<AtIdentifier<DefaultStr>>), 33 + Followers(AtIdentifier<DefaultStr>), 34 + ReactionsList(Option<AtIdentifier<DefaultStr>>), 35 + ReactionsOn(AtUri<DefaultStr>), 36 + } 37 + 38 + #[derive(Debug, Error)] 39 + pub enum SocialListError { 40 + #[error(transparent)] 41 + Appview(#[from] AppviewError), 42 + #[error(transparent)] 43 + Locate(#[from] LocateError), 44 + #[error(transparent)] 45 + Session(#[from] SessionError), 46 + #[error(transparent)] 47 + Oauth(Box<OauthError>), 48 + #[error(transparent)] 49 + Rkey(#[from] RkeyFromUriError), 50 + #[error("repo `{slug}` has no repo DID. Read against legacy repos is unsupported.")] 51 + LegacyRepo { slug: String }, 52 + #[error("output write failed: {0}")] 53 + Output(#[source] io::Error), 54 + } 55 + 56 + impl From<OauthError> for SocialListError { 57 + fn from(e: OauthError) -> Self { 58 + Self::Oauth(Box::new(e)) 59 + } 60 + } 61 + 62 + pub fn exit_kind(err: &SocialListError) -> crate::ExitKind { 63 + use crate::ExitKind; 64 + match err { 65 + SocialListError::Rkey(_) | SocialListError::LegacyRepo { .. } => ExitKind::UserError, 66 + SocialListError::Session(e) => session_exit_kind(e), 67 + SocialListError::Appview(AppviewError::NotFound) => ExitKind::NotFound, 68 + SocialListError::Appview(_) | SocialListError::Oauth(_) => ExitKind::NetworkOrAuth, 69 + SocialListError::Locate(e) => crate::locate_exit_kind(e), 70 + SocialListError::Output(_) => ExitKind::Internal, 71 + } 72 + } 73 + 74 + pub async fn run( 75 + action: SocialListAction, 76 + ctx: &SessionCtx, 77 + mode: Mode, 78 + out: &mut dyn Write, 79 + ) -> Result<(), SocialListError> { 80 + match action { 81 + SocialListAction::StarsList(user) => stars_by_actor(ctx, user, mode, out).await, 82 + SocialListAction::StarsOn(repo) => stars_on_repo(ctx, repo, mode, out).await, 83 + SocialListAction::FollowsList(user) => follows_by_actor(ctx, user, mode, out).await, 84 + SocialListAction::Followers(user) => followers_of(ctx, user, mode, out).await, 85 + SocialListAction::ReactionsList(user) => reactions_by_actor(ctx, user, mode, out).await, 86 + SocialListAction::ReactionsOn(at_uri) => reactions_on(ctx, at_uri, mode, out).await, 87 + } 88 + } 89 + 90 + async fn resolve_user_or_active( 91 + ctx: &SessionCtx, 92 + user: Option<AtIdentifier<DefaultStr>>, 93 + ) -> Result<Did, SocialListError> { 94 + match user { 95 + Some(AtIdentifier::Did(d)) => Ok(d), 96 + Some(AtIdentifier::Handle(h)) => Ok(ctx.discovery.resolve_handle(&h).await?), 97 + None => Ok(ctx.active_did().await?), 98 + } 99 + } 100 + 101 + async fn resolve_user_required( 102 + ctx: &SessionCtx, 103 + user: AtIdentifier<DefaultStr>, 104 + ) -> Result<Did, SocialListError> { 105 + match user { 106 + AtIdentifier::Did(d) => Ok(d), 107 + AtIdentifier::Handle(h) => Ok(ctx.discovery.resolve_handle(&h).await?), 108 + } 109 + } 110 + 111 + fn require_repo_did(locator: &RepoLocator) -> Result<Did, SocialListError> { 112 + locator 113 + .record 114 + .repo_did 115 + .clone() 116 + .ok_or_else(|| SocialListError::LegacyRepo { 117 + slug: locator.canonical_slug(), 118 + }) 119 + } 120 + 121 + async fn stars_by_actor( 122 + ctx: &SessionCtx, 123 + user: Option<AtIdentifier<DefaultStr>>, 124 + mode: Mode, 125 + out: &mut dyn Write, 126 + ) -> Result<(), SocialListError> { 127 + let actor_did = resolve_user_or_active(ctx, user).await?; 128 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 129 + resolve_did_handle(&ctx.appview, &actor_did, &mut handles).await; 130 + 131 + let mut sink = ListSink::new(out, mode, STAR_LIST_COLUMNS, DEFAULT_LIST_CAP); 132 + let bounded = matches!(mode, Mode::Text { .. }); 133 + let stream = ctx.appview.paginate_stars_by(&actor_did).into_stream(); 134 + let mut stream = std::pin::pin!(stream); 135 + let mut pushed = 0usize; 136 + while let Some(rec) = stream.try_next().await? { 137 + match &rec.value.subject { 138 + StarSubject::Repo(r) => { 139 + resolve_did_handle(&ctx.appview, &r.did, &mut handles).await; 140 + } 141 + StarSubject::String(s) => { 142 + resolve_author_handle(&ctx.appview, &s.uri, &mut handles).await; 143 + } 144 + } 145 + let row = build_star_row(rec, &handles)?; 146 + sink.push(&row, |p| star_list_row_cells(&row, p)) 147 + .map_err(SocialListError::Output)?; 148 + pushed += 1; 149 + if bounded && pushed > DEFAULT_LIST_CAP { 150 + break; 151 + } 152 + } 153 + let actor = did_label(&actor_did, &handles); 154 + sink.finish(&format!("no stars by {actor}")) 155 + .map_err(SocialListError::Output) 156 + } 157 + 158 + async fn stars_on_repo( 159 + ctx: &SessionCtx, 160 + repo: RepoRef, 161 + mode: Mode, 162 + out: &mut dyn Write, 163 + ) -> Result<(), SocialListError> { 164 + let locator = repo_locator::locate(&ctx.appview, &ctx.discovery, repo).await?; 165 + let repo_did = require_repo_did(&locator)?; 166 + let slug = locator.canonical_slug(); 167 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 168 + resolve_did_handle(&ctx.appview, &repo_did, &mut handles).await; 169 + 170 + let mut sink = ListSink::new(out, mode, STAR_ON_REPO_COLUMNS, DEFAULT_LIST_CAP); 171 + let bounded = matches!(mode, Mode::Text { .. }); 172 + let stream = ctx.appview.paginate_stars(&repo_did).into_stream(); 173 + let mut stream = std::pin::pin!(stream); 174 + let mut pushed = 0usize; 175 + while let Some(rec) = stream.try_next().await? { 176 + resolve_author_handle(&ctx.appview, &rec.uri, &mut handles).await; 177 + let row = build_star_row(rec, &handles)?; 178 + sink.push(&row, |p| star_on_repo_row_cells(&row, p)) 179 + .map_err(SocialListError::Output)?; 180 + pushed += 1; 181 + if bounded && pushed > DEFAULT_LIST_CAP { 182 + break; 183 + } 184 + } 185 + sink.finish(&format!("no stars on {slug}")) 186 + .map_err(SocialListError::Output) 187 + } 188 + 189 + async fn follows_by_actor( 190 + ctx: &SessionCtx, 191 + user: Option<AtIdentifier<DefaultStr>>, 192 + mode: Mode, 193 + out: &mut dyn Write, 194 + ) -> Result<(), SocialListError> { 195 + let actor_did = resolve_user_or_active(ctx, user).await?; 196 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 197 + resolve_did_handle(&ctx.appview, &actor_did, &mut handles).await; 198 + 199 + let mut sink = ListSink::new(out, mode, FOLLOW_LIST_COLUMNS, DEFAULT_LIST_CAP); 200 + let bounded = matches!(mode, Mode::Text { .. }); 201 + let stream = ctx.appview.paginate_follows_by(&actor_did).into_stream(); 202 + let mut stream = std::pin::pin!(stream); 203 + let mut pushed = 0usize; 204 + while let Some(rec) = stream.try_next().await? { 205 + resolve_did_handle(&ctx.appview, &rec.value.subject, &mut handles).await; 206 + let row = build_follow_row(rec, &handles)?; 207 + sink.push(&row, |p| follow_list_row_cells(&row, p)) 208 + .map_err(SocialListError::Output)?; 209 + pushed += 1; 210 + if bounded && pushed > DEFAULT_LIST_CAP { 211 + break; 212 + } 213 + } 214 + let actor = did_label(&actor_did, &handles); 215 + sink.finish(&format!("no follows by {actor}")) 216 + .map_err(SocialListError::Output) 217 + } 218 + 219 + async fn followers_of( 220 + ctx: &SessionCtx, 221 + user: AtIdentifier<DefaultStr>, 222 + mode: Mode, 223 + out: &mut dyn Write, 224 + ) -> Result<(), SocialListError> { 225 + let subject_did = resolve_user_required(ctx, user).await?; 226 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 227 + resolve_did_handle(&ctx.appview, &subject_did, &mut handles).await; 228 + 229 + let mut sink = ListSink::new(out, mode, FOLLOW_LIST_COLUMNS, DEFAULT_LIST_CAP); 230 + let bounded = matches!(mode, Mode::Text { .. }); 231 + let stream = ctx.appview.paginate_follows(&subject_did).into_stream(); 232 + let mut stream = std::pin::pin!(stream); 233 + let mut pushed = 0usize; 234 + while let Some(rec) = stream.try_next().await? { 235 + resolve_author_handle(&ctx.appview, &rec.uri, &mut handles).await; 236 + let row = build_follow_row(rec, &handles)?; 237 + sink.push(&row, |p| follow_list_row_cells(&row, p)) 238 + .map_err(SocialListError::Output)?; 239 + pushed += 1; 240 + if bounded && pushed > DEFAULT_LIST_CAP { 241 + break; 242 + } 243 + } 244 + let subject = did_label(&subject_did, &handles); 245 + sink.finish(&format!("no followers of {subject}")) 246 + .map_err(SocialListError::Output) 247 + } 248 + 249 + async fn reactions_by_actor( 250 + ctx: &SessionCtx, 251 + user: Option<AtIdentifier<DefaultStr>>, 252 + mode: Mode, 253 + out: &mut dyn Write, 254 + ) -> Result<(), SocialListError> { 255 + let actor_did = resolve_user_or_active(ctx, user).await?; 256 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 257 + resolve_did_handle(&ctx.appview, &actor_did, &mut handles).await; 258 + 259 + let mut sink = ListSink::new(out, mode, REACTION_LIST_COLUMNS, DEFAULT_LIST_CAP); 260 + let bounded = matches!(mode, Mode::Text { .. }); 261 + let stream = ctx.appview.paginate_reactions_by(&actor_did).into_stream(); 262 + let mut stream = std::pin::pin!(stream); 263 + let mut pushed = 0usize; 264 + while let Some(rec) = stream.try_next().await? { 265 + resolve_author_handle(&ctx.appview, &rec.value.subject, &mut handles).await; 266 + let row = build_reaction_row(rec, &handles)?; 267 + sink.push(&row, |p| reaction_list_row_cells(&row, p)) 268 + .map_err(SocialListError::Output)?; 269 + pushed += 1; 270 + if bounded && pushed > DEFAULT_LIST_CAP { 271 + break; 272 + } 273 + } 274 + let actor = did_label(&actor_did, &handles); 275 + sink.finish(&format!("no reactions by {actor}")) 276 + .map_err(SocialListError::Output) 277 + } 278 + 279 + async fn reactions_on( 280 + ctx: &SessionCtx, 281 + at_uri: AtUri<DefaultStr>, 282 + mode: Mode, 283 + out: &mut dyn Write, 284 + ) -> Result<(), SocialListError> { 285 + let mut handles: HashMap<Did, Handle<DefaultStr>> = HashMap::new(); 286 + resolve_author_handle(&ctx.appview, &at_uri, &mut handles).await; 287 + 288 + let mut sink = ListSink::new(out, mode, REACTION_LIST_COLUMNS, DEFAULT_LIST_CAP); 289 + let bounded = matches!(mode, Mode::Text { .. }); 290 + let stream = ctx.appview.paginate_reactions(&at_uri).into_stream(); 291 + let mut stream = std::pin::pin!(stream); 292 + let mut pushed = 0usize; 293 + while let Some(rec) = stream.try_next().await? { 294 + resolve_author_handle(&ctx.appview, &rec.uri, &mut handles).await; 295 + let row = build_reaction_row(rec, &handles)?; 296 + sink.push(&row, |p| reaction_list_row_cells(&row, p)) 297 + .map_err(SocialListError::Output)?; 298 + pushed += 1; 299 + if bounded && pushed > DEFAULT_LIST_CAP { 300 + break; 301 + } 302 + } 303 + let subject = display_uri(&at_uri, &handles); 304 + sink.finish(&format!("no reactions on {subject}")) 305 + .map_err(SocialListError::Output) 306 + } 307 + 308 + fn build_star_row( 309 + rec: RecordView<Star<DefaultStr>>, 310 + handles: &HashMap<Did, Handle<DefaultStr>>, 311 + ) -> Result<StarListRow, SocialListError> { 312 + let RecordView { 313 + uri, 314 + cid, 315 + value: Star { 316 + subject, 317 + created_at, 318 + .. 319 + }, 320 + } = rec; 321 + let rkey = rkey_from_uri(&uri)?; 322 + let actor = author_label(&uri, handles); 323 + let (subject_kind, subject_label) = match subject { 324 + StarSubject::Repo(r) => (StarSubjectKind::Repo, did_label(&r.did, handles)), 325 + StarSubject::String(s) => (StarSubjectKind::String, display_uri(&s.uri, handles)), 326 + }; 327 + Ok(StarListRow { 328 + uri, 329 + cid, 330 + rkey, 331 + actor, 332 + subject_kind, 333 + subject: subject_label, 334 + created_at, 335 + }) 336 + } 337 + 338 + fn build_follow_row( 339 + rec: RecordView<Follow<DefaultStr>>, 340 + handles: &HashMap<Did, Handle<DefaultStr>>, 341 + ) -> Result<FollowListRow, SocialListError> { 342 + let RecordView { 343 + uri, 344 + cid, 345 + value: Follow { 346 + subject, 347 + created_at, 348 + .. 349 + }, 350 + } = rec; 351 + let rkey = rkey_from_uri(&uri)?; 352 + let actor = author_label(&uri, handles); 353 + let subject_label = did_label(&subject, handles); 354 + Ok(FollowListRow { 355 + uri, 356 + cid, 357 + rkey, 358 + actor, 359 + subject: subject_label, 360 + created_at, 361 + }) 362 + } 363 + 364 + fn build_reaction_row( 365 + rec: RecordView<Reaction<DefaultStr>>, 366 + handles: &HashMap<Did, Handle<DefaultStr>>, 367 + ) -> Result<ReactionListRow, SocialListError> { 368 + let RecordView { 369 + uri, 370 + cid, 371 + value: Reaction { 372 + reaction, 373 + subject, 374 + created_at, 375 + .. 376 + }, 377 + } = rec; 378 + let rkey = rkey_from_uri(&uri)?; 379 + let actor = author_label(&uri, handles); 380 + let subject_label = display_uri(&subject, handles); 381 + Ok(ReactionListRow { 382 + uri, 383 + cid, 384 + rkey, 385 + actor, 386 + emoji: reaction.to_string(), 387 + subject: subject_label, 388 + created_at, 389 + }) 390 + } 391 + 392 + #[cfg(test)] 393 + mod tests { 394 + use super::*; 395 + use jacquard_common::types::string::{Datetime, Did as StrDid}; 396 + use smol_str::SmolStr; 397 + use superjam_lexicon::sh_tangled::feed::star::{self, StarString}; 398 + 399 + fn nel_did() -> Did { 400 + Did::new_owned("did:plc:nel").unwrap() 401 + } 402 + 403 + fn nel_handle_map() -> HashMap<Did, Handle<DefaultStr>> { 404 + let mut m = HashMap::new(); 405 + m.insert(nel_did(), Handle::new_owned("nel.pet").unwrap()); 406 + m 407 + } 408 + 409 + fn star_uri() -> AtUri<DefaultStr> { 410 + AtUri::<DefaultStr>::from_parts_owned( 411 + "did:plc:nel", 412 + "sh.tangled.feed.star", 413 + "3lqj1aaaaaaaa", 414 + ) 415 + .unwrap() 416 + } 417 + 418 + fn follow_uri() -> AtUri<DefaultStr> { 419 + AtUri::<DefaultStr>::from_parts_owned( 420 + "did:plc:nel", 421 + "sh.tangled.graph.follow", 422 + "3lqj1aaaaaaaa", 423 + ) 424 + .unwrap() 425 + } 426 + 427 + fn reaction_uri() -> AtUri<DefaultStr> { 428 + AtUri::<DefaultStr>::from_parts_owned( 429 + "did:plc:nel", 430 + "sh.tangled.feed.reaction", 431 + "3lqj1aaaaaaaa", 432 + ) 433 + .unwrap() 434 + } 435 + 436 + fn issue_uri() -> AtUri<DefaultStr> { 437 + AtUri::<DefaultStr>::from_parts_owned( 438 + "did:plc:limpet", 439 + "sh.tangled.repo.issue", 440 + "3jzfcijpj2z2a", 441 + ) 442 + .unwrap() 443 + } 444 + 445 + fn star_with_repo_subject(subject_did: &str) -> RecordView<Star<DefaultStr>> { 446 + RecordView { 447 + uri: star_uri(), 448 + cid: None, 449 + value: Star { 450 + created_at: Datetime::raw_str("2026-05-01T00:00:00.000Z"), 451 + subject: StarSubject::Repo(Box::new(star::Repo { 452 + did: StrDid::<DefaultStr>::new_owned(subject_did).unwrap(), 453 + extra_data: None, 454 + })), 455 + extra_data: None, 456 + }, 457 + } 458 + } 459 + 460 + fn star_with_string_subject(subject_uri: AtUri<DefaultStr>) -> RecordView<Star<DefaultStr>> { 461 + RecordView { 462 + uri: star_uri(), 463 + cid: None, 464 + value: Star { 465 + created_at: Datetime::raw_str("2026-05-01T00:00:00.000Z"), 466 + subject: StarSubject::String(Box::new(StarString { 467 + uri: subject_uri, 468 + extra_data: None, 469 + })), 470 + extra_data: None, 471 + }, 472 + } 473 + } 474 + 475 + fn follow_record(subject_did: &str) -> RecordView<Follow<DefaultStr>> { 476 + RecordView { 477 + uri: follow_uri(), 478 + cid: None, 479 + value: Follow { 480 + created_at: Datetime::raw_str("2026-05-01T00:00:00.000Z"), 481 + subject: StrDid::<DefaultStr>::new_owned(subject_did).unwrap(), 482 + extra_data: None, 483 + }, 484 + } 485 + } 486 + 487 + fn reaction_record(emoji: &str, subject: AtUri<DefaultStr>) -> RecordView<Reaction<DefaultStr>> { 488 + RecordView { 489 + uri: reaction_uri(), 490 + cid: None, 491 + value: Reaction { 492 + created_at: Datetime::raw_str("2026-05-01T00:00:00.000Z"), 493 + reaction: SmolStr::from(emoji), 494 + subject, 495 + extra_data: None, 496 + }, 497 + } 498 + } 499 + 500 + #[test] 501 + fn build_star_row_uses_handle_for_repo_subject_when_known() { 502 + let rec = star_with_repo_subject("did:plc:nel"); 503 + let row = build_star_row(rec, &nel_handle_map()).unwrap(); 504 + assert_eq!(row.actor, "nel.pet"); 505 + assert_eq!(row.subject_kind, StarSubjectKind::Repo); 506 + assert_eq!(row.subject, "nel.pet"); 507 + } 508 + 509 + #[test] 510 + fn build_star_row_falls_back_to_did_when_repo_handle_unknown() { 511 + let rec = star_with_repo_subject("did:plc:limpet"); 512 + let row = build_star_row(rec, &nel_handle_map()).unwrap(); 513 + assert_eq!(row.subject_kind, StarSubjectKind::Repo); 514 + assert_eq!(row.subject, "did:plc:limpet"); 515 + } 516 + 517 + #[test] 518 + fn build_star_row_rewrites_string_subject_authority_when_handle_known() { 519 + let rec = star_with_string_subject(issue_uri()); 520 + let mut handles = nel_handle_map(); 521 + handles.insert( 522 + Did::new_owned("did:plc:limpet").unwrap(), 523 + Handle::new_owned("olaren.dev").unwrap(), 524 + ); 525 + let row = build_star_row(rec, &handles).unwrap(); 526 + assert_eq!(row.subject_kind, StarSubjectKind::String); 527 + assert_eq!( 528 + row.subject, 529 + "at://olaren.dev/sh.tangled.repo.issue/3jzfcijpj2z2a", 530 + ); 531 + } 532 + 533 + #[test] 534 + fn build_star_row_string_subject_falls_back_to_did_uri_when_unknown() { 535 + let rec = star_with_string_subject(issue_uri()); 536 + let row = build_star_row(rec, &nel_handle_map()).unwrap(); 537 + assert_eq!( 538 + row.subject, 539 + "at://did:plc:limpet/sh.tangled.repo.issue/3jzfcijpj2z2a", 540 + ); 541 + } 542 + 543 + #[test] 544 + fn build_follow_row_uses_handle_for_subject_when_known() { 545 + let rec = follow_record("did:plc:nel"); 546 + let row = build_follow_row(rec, &nel_handle_map()).unwrap(); 547 + assert_eq!(row.actor, "nel.pet"); 548 + assert_eq!(row.subject, "nel.pet"); 549 + } 550 + 551 + #[test] 552 + fn build_follow_row_falls_back_to_did_when_subject_unknown() { 553 + let rec = follow_record("did:plc:olaren"); 554 + let row = build_follow_row(rec, &nel_handle_map()).unwrap(); 555 + assert_eq!(row.subject, "did:plc:olaren"); 556 + } 557 + 558 + #[test] 559 + fn build_reaction_row_rewrites_subject_authority_when_handle_known() { 560 + let rec = reaction_record("\u{1F980}", issue_uri()); 561 + let mut handles = nel_handle_map(); 562 + handles.insert( 563 + Did::new_owned("did:plc:limpet").unwrap(), 564 + Handle::new_owned("olaren.dev").unwrap(), 565 + ); 566 + let row = build_reaction_row(rec, &handles).unwrap(); 567 + assert_eq!(row.actor, "nel.pet"); 568 + assert_eq!(row.emoji, "\u{1F980}"); 569 + assert_eq!( 570 + row.subject, 571 + "at://olaren.dev/sh.tangled.repo.issue/3jzfcijpj2z2a", 572 + ); 573 + } 574 + 575 + #[test] 576 + fn build_reaction_row_keeps_did_authority_when_handle_unknown() { 577 + let rec = reaction_record("\u{2764}", issue_uri()); 578 + let row = build_reaction_row(rec, &nel_handle_map()).unwrap(); 579 + assert_eq!( 580 + row.subject, 581 + "at://did:plc:limpet/sh.tangled.repo.issue/3jzfcijpj2z2a", 582 + ); 583 + } 584 + }
+43 -2
crates/superjam/src/main.rs
··· 15 15 mod format; 16 16 17 17 use cli::{ 18 - AuthCmd, Cli, Command, ConfigCmd, FollowArgs, IssueCmd, PrCmd, ProfileCmd, ReactArgs, RepoCmd, 19 - StarArgs, 18 + AuthCmd, Cli, Command, ConfigCmd, FollowArgs, FollowersArgs, FollowsAction, FollowsCmd, 19 + IssueCmd, PrCmd, ProfileCmd, ReactArgs, ReactionsAction, ReactionsCmd, RepoCmd, StarArgs, 20 + StarsAction, StarsCmd, 20 21 }; 21 22 use cmd::auth::AuthError; 22 23 use cmd::issue::IssueError; ··· 25 26 use cmd::repo::RepoError; 26 27 use cmd::repo_locator::LocateError; 27 28 use cmd::social::{SocialAction, SocialError}; 29 + use cmd::social_list::{SocialListAction, SocialListError}; 28 30 use context::{ContextError, IdentityResolveError}; 29 31 use format::ColorMode; 30 32 ··· 92 94 #[error(transparent)] 93 95 Social(#[from] SocialError), 94 96 #[error(transparent)] 97 + SocialList(#[from] SocialListError), 98 + #[error(transparent)] 95 99 Profile(#[from] ProfileError), 96 100 #[error("`{0}` is not yet implemented")] 97 101 Pending(&'static str), ··· 108 112 Self::Issue(e) => cmd::issue::exit_kind(e), 109 113 Self::Pr(e) => cmd::pr::exit_kind(e), 110 114 Self::Social(e) => cmd::social::exit_kind(e), 115 + Self::SocialList(e) => cmd::social_list::exit_kind(e), 111 116 Self::Profile(e) => cmd::profile::exit_kind(e), 112 117 Self::Pending(_) => ExitKind::UserError, 113 118 Self::Output(_) => ExitKind::Internal, ··· 122 127 Self::Issue(_) => "issue", 123 128 Self::Pr(_) => "pr", 124 129 Self::Social(_) => "social", 130 + Self::SocialList(_) => "social", 125 131 Self::Profile(_) => "profile", 126 132 Self::Pending(_) => "not-implemented", 127 133 Self::Output(_) => "output", ··· 294 300 let repo = context::resolve_repo_or_cwd(&global, repo)?; 295 301 run_social(SocialAction::Unstar(repo), &global, mode, out).await 296 302 } 303 + Command::Stars(StarsCmd { action }) => { 304 + let action = match action { 305 + StarsAction::List { user } => SocialListAction::StarsList(user), 306 + StarsAction::On { repo } => { 307 + let repo = context::resolve_repo_or_cwd(&global, repo)?; 308 + SocialListAction::StarsOn(repo) 309 + } 310 + }; 311 + run_social_list(action, &global, mode, out).await 312 + } 313 + Command::Follows(FollowsCmd { action }) => { 314 + let FollowsAction::List { user } = action; 315 + run_social_list(SocialListAction::FollowsList(user), &global, mode, out).await 316 + } 317 + Command::Followers(FollowersArgs { user }) => { 318 + run_social_list(SocialListAction::Followers(user), &global, mode, out).await 319 + } 320 + Command::Reactions(ReactionsCmd { action }) => { 321 + let action = match action { 322 + ReactionsAction::List { user } => SocialListAction::ReactionsList(user), 323 + ReactionsAction::On { at_uri } => SocialListAction::ReactionsOn(at_uri), 324 + }; 325 + run_social_list(action, &global, mode, out).await 326 + } 297 327 Command::Repo(RepoCmd { action }) => { 298 328 let ctx = cmd::repo::RepoCtx::load(&global)?; 299 329 cmd::repo::run(action, &ctx, &global, mode, out).await?; ··· 320 350 ) -> Result<(), CliError> { 321 351 let ctx = cmd::session::SessionCtx::load(global).map_err(SocialError::Session)?; 322 352 cmd::social::run(action, &ctx, mode, out).await?; 353 + Ok(()) 354 + } 355 + 356 + async fn run_social_list( 357 + action: SocialListAction, 358 + global: &cli::GlobalOpts, 359 + mode: Mode, 360 + out: &mut dyn Write, 361 + ) -> Result<(), CliError> { 362 + let ctx = cmd::session::SessionCtx::load(global).map_err(SocialListError::Session)?; 363 + cmd::social_list::run(action, &ctx, mode, out).await?; 323 364 Ok(()) 324 365 } 325 366