ItsGoin v0.3.2 — Decentralized social media network

No central server, user-owned data, reverse-chronological feed.
Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-15 20:22:08 -04:00
commit 800388cda4
146 changed files with 53227 additions and 0 deletions

16
crates/cli/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "itsgoin-cli"
version = "0.3.0"
edition = "2021"
[[bin]]
name = "itsgoin"
path = "src/main.rs"
[dependencies]
itsgoin-core = { path = "../core" }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
hex = "0.4"

920
crates/cli/src/main.rs Normal file
View file

@ -0,0 +1,920 @@
use std::io::{self, BufRead, Write};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use itsgoin_core::node::Node;
use itsgoin_core::types::DeviceProfile;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
)
.init();
let args: Vec<String> = std::env::args().collect();
// Parse flags before anything else
let mut data_dir = String::from("./itsgoin-data");
let mut import_key: Option<String> = None;
let mut bind_addr: Option<SocketAddr> = None;
let mut daemon = false;
let mut mobile = false;
let mut web_port: Option<u16> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--import-key" => {
if i + 1 >= args.len() {
eprintln!("Error: --import-key requires a 64-char hex key argument");
std::process::exit(1);
}
import_key = Some(args[i + 1].clone());
i += 2;
}
"--bind" => {
if i + 1 >= args.len() {
eprintln!("Error: --bind requires an address argument (e.g. 0.0.0.0:4433)");
std::process::exit(1);
}
bind_addr = Some(args[i + 1].parse().unwrap_or_else(|e| {
eprintln!("Error: invalid bind address '{}': {}", args[i + 1], e);
std::process::exit(1);
}));
i += 2;
}
"--daemon" => {
daemon = true;
i += 1;
}
"--mobile" => {
mobile = true;
i += 1;
}
"--web" => {
if i + 1 >= args.len() {
eprintln!("Error: --web requires a port argument (e.g. --web 8080)");
std::process::exit(1);
}
web_port = Some(args[i + 1].parse().unwrap_or_else(|e| {
eprintln!("Error: invalid web port '{}': {}", args[i + 1], e);
std::process::exit(1);
}));
i += 2;
}
_ => {
if data_dir == "./itsgoin-data" {
data_dir = args[i].clone();
}
i += 1;
}
}
}
// Handle --import-key before opening the node
if let Some(hex_key) = import_key {
println!("Importing identity key into {}...", data_dir);
match Node::import_identity(Path::new(&data_dir), &hex_key) {
Ok(()) => println!("Identity key imported successfully."),
Err(e) => {
eprintln!("Error importing key: {}", e);
std::process::exit(1);
}
}
}
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
// Wait briefly for address resolution
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let node_id_hex = hex::encode(node.node_id);
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
// Show our display name if set
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
let name_display = my_name.as_deref().unwrap_or("(not set)");
println!();
println!("========================================");
println!(" ItsGoin node running");
println!(" Name: {}", name_display);
println!(" Node ID: {}", node_id_hex);
for sock in &sockets {
println!(" Listen: {}", sock);
}
println!("========================================");
// Print a connect string others can paste
if let Some(sock) = sockets.first() {
println!(" Share this to connect:");
println!(" connect {}@{}", node_id_hex, sock);
}
println!();
println!("Commands:");
println!(" post <text> Create a public post");
println!(" post-to <target> <text> Encrypted post (circle name, node_id, or 'friends')");
println!(" feed Show your feed");
println!(" posts Show all known posts");
println!(" follow <node_id> Follow a node");
println!(" unfollow <node_id> Unfollow a node");
println!(" follows List followed nodes");
println!(" connect <id>@<ip:port> Connect to a peer and sync");
println!(" connect <node_id> Connect via address resolution");
println!(" sync Sync with all known peers");
println!(" peers List known peers");
println!(" circles List circles");
println!(" create-circle <name> Create a circle");
println!(" delete-circle <name> Delete a circle");
println!(" add-to-circle <circle> <node_id> Add member to circle");
println!(" remove-from-circle <circle> <id> Remove member from circle");
println!(" delete <post_id_hex> Delete one of your posts");
println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)");
println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node");
println!(" redundancy Show replica counts for your posts");
println!(" audience List audience members");
println!(" audience-request <node_id> Request to join peer's audience");
println!(" audience-pending Show pending audience requests");
println!(" audience-approve <node_id> Approve audience request");
println!(" audience-remove <node_id> Remove from audience");
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections");
println!(" social-routes Show social routing cache");
println!(" name <display_name> Set your display name");
println!(" stats Show node stats");
println!(" export-key Export identity key (KEEP SECRET)");
println!(" id Show this node's ID");
println!(" quit Shut down");
println!();
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
let _web_handle = if let Some(wp) = web_port {
Some(node.start_web_handler(wp))
} else {
None
};
if daemon {
println!("Running as daemon. Press Ctrl+C to stop.");
tokio::signal::ctrl_c().await?;
println!("Shutting down...");
return Ok(());
}
// Interactive command loop
let stdin = io::stdin();
let reader = stdin.lock();
print!("> ");
io::stdout().flush()?;
for line in reader.lines() {
let line = line?;
let line = line.trim().to_string();
if line.is_empty() {
print!("> ");
io::stdout().flush()?;
continue;
}
let parts: Vec<&str> = line.splitn(2, ' ').collect();
let cmd = parts[0];
let arg = parts.get(1).map(|s| s.trim());
match cmd {
"post" => {
if let Some(text) = arg {
match node.create_post(text.to_string()).await {
Ok((id, _post)) => {
println!("Posted! ID: {}", hex::encode(id));
}
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: post <text>");
}
}
"feed" => match node.get_feed().await {
Ok(posts) => {
if posts.is_empty() {
println!("(feed is empty - follow some nodes first)");
}
for (id, post, vis, decrypted) in posts {
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
}
}
Err(e) => println!("Error: {}", e),
},
"posts" => match node.get_all_posts().await {
Ok(posts) => {
if posts.is_empty() {
println!("(no posts yet)");
}
for (id, post, vis, decrypted) in posts {
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
}
}
Err(e) => println!("Error: {}", e),
},
"follow" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
node.follow(&nid).await?;
println!("Following {}", &id_hex[..16.min(id_hex.len())]);
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: follow <node_id_hex>");
}
}
"unfollow" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
node.unfollow(&nid).await?;
println!("Unfollowed {}", &id_hex[..16.min(id_hex.len())]);
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: unfollow <node_id_hex>");
}
}
"follows" => match node.list_follows().await {
Ok(follows) => {
if follows.is_empty() {
println!("(not following anyone)");
}
for nid in follows {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let label = if nid == node.node_id { " (you)" } else { "" };
if let Some(name) = name {
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
} else {
println!(" {}{}", hex::encode(nid), label);
}
}
}
Err(e) => println!("Error: {}", e),
},
"connect" => {
if let Some(addr_str) = arg {
if addr_str.contains('@') {
// Full connect string: node_id@ip:port
match itsgoin_core::parse_connect_string(addr_str) {
Ok((nid, endpoint_addr)) => {
println!("Connecting and syncing...");
node.add_peer(nid).await?;
node.follow(&nid).await?;
match node.sync_with_addr(endpoint_addr).await {
Ok(()) => {
let name = node.get_display_name(&nid).await.unwrap_or(None);
if let Some(name) = name {
println!("Sync complete with {}!", name);
} else {
println!("Sync complete!");
}
}
Err(e) => println!("Sync failed: {}", e),
}
}
Err(e) => println!("Invalid address: {}", e),
}
} else {
// Node ID only: use address resolution (N2/N3 + worm)
match itsgoin_core::parse_node_id_hex(addr_str) {
Ok(nid) => {
println!("Resolving address...");
node.add_peer(nid).await?;
node.follow(&nid).await?;
match node.sync_with(nid).await {
Ok(()) => {
let name = node.get_display_name(&nid).await.unwrap_or(None);
if let Some(name) = name {
println!("Connected to {}!", name);
} else {
println!("Connected!");
}
}
Err(e) => println!("Address resolution failed: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: connect <node_id@ip:port> or connect <node_id>");
}
}
"sync" => {
println!("Syncing with all known peers...");
match node.sync_all().await {
Ok(()) => println!("Sync complete!"),
Err(e) => println!("Sync error: {}", e),
}
}
"post-to" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: post-to <target> <text>");
println!(" target: 'friends', circle name, or node_id hex");
} else {
let target = parts[0];
let text = parts[1];
let intent = if target == "friends" {
itsgoin_core::types::VisibilityIntent::Friends
} else if let Ok(nid) = itsgoin_core::parse_node_id_hex(target) {
itsgoin_core::types::VisibilityIntent::Direct(vec![nid])
} else {
itsgoin_core::types::VisibilityIntent::Circle(target.to_string())
};
match node
.create_post_with_visibility(text.to_string(), intent, vec![])
.await
{
Ok((id, _post, _vis)) => {
println!("Encrypted post! ID: {}", hex::encode(id));
}
Err(e) => println!("Error: {}", e),
}
}
} else {
println!("Usage: post-to <target> <text>");
}
}
"circles" => match node.list_circles().await {
Ok(circles) => {
if circles.is_empty() {
println!("(no circles)");
}
for c in circles {
println!(
" {} ({} members, created {})",
c.name,
c.members.len(),
chrono_lite(c.created_at / 1000)
);
for m in &c.members {
let name = node.get_display_name(m).await.unwrap_or(None);
let label = name.unwrap_or_else(|| hex::encode(m)[..12].to_string());
println!(" - {}", label);
}
}
}
Err(e) => println!("Error: {}", e),
},
"create-circle" => {
if let Some(name) = arg {
match node.create_circle(name.to_string()).await {
Ok(()) => println!("Circle '{}' created", name),
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: create-circle <name>");
}
}
"delete-circle" => {
if let Some(name) = arg {
match node.delete_circle(name.to_string()).await {
Ok(()) => println!("Circle '{}' deleted", name),
Err(e) => println!("Error: {}", e),
}
} else {
println!("Usage: delete-circle <name>");
}
}
"add-to-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(nid) => {
match node.add_to_circle(parts[0].to_string(), nid).await {
Ok(()) => println!("Added to circle '{}'", parts[0]),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
}
}
"remove-from-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(nid) => {
match node.remove_from_circle(parts[0].to_string(), nid).await {
Ok(()) => println!("Removed from circle '{}'", parts[0]),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
}
}
"delete" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(post_id) => match node.delete_post(&post_id).await {
Ok(()) => println!("Post deleted: {}", &id_hex[..16.min(id_hex.len())]),
Err(e) => println!("Error: {}", e),
},
Err(e) => println!("Invalid post ID: {}", e),
}
} else {
println!("Usage: delete <post_id_hex>");
}
}
"revoke" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() < 2 {
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
} else {
let mode = match parts.get(2).unwrap_or(&"sync") {
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
};
match (
itsgoin_core::parse_node_id_hex(parts[0]),
itsgoin_core::parse_node_id_hex(parts[1]),
) {
(Ok(post_id), Ok(node_id)) => {
match node.revoke_post_access(&post_id, &node_id, mode).await {
Ok(Some(new_id)) => {
println!("Re-encrypted. New post ID: {}", hex::encode(new_id));
}
Ok(None) => println!("Access revoked (sync mode)"),
Err(e) => println!("Error: {}", e),
}
}
(Err(e), _) => println!("Invalid post ID: {}", e),
(_, Err(e)) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
}
}
"revoke-circle" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() < 2 {
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
} else {
let mode = match parts.get(2).unwrap_or(&"sync") {
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
};
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(node_id) => {
match node.revoke_circle_access(parts[0], &node_id, mode).await {
Ok(count) => println!("Revoked access on {} posts", count),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
}
} else {
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
}
}
"redundancy" => {
match node.get_redundancy_summary().await {
Ok((total, zero, one, two_plus)) => {
println!("Redundancy for your {} authored posts:", total);
println!(" No replicas: {} posts", zero);
println!(" 1 replica: {} posts", one);
println!(" 2+ replicas: {} posts", two_plus);
if zero > 0 {
println!(" WARNING: {} posts have no peer replicas!", zero);
}
}
Err(e) => println!("Error: {}", e),
}
}
"peers" => match node.list_peer_records().await {
Ok(records) => {
if records.is_empty() {
println!("(no known peers)");
}
for rec in records {
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
let id_short = &hex::encode(rec.node_id)[..12];
let label = if let Some(name) = name {
format!("{} ({})", name, id_short)
} else {
hex::encode(rec.node_id)
};
let anchor = if rec.is_anchor { " [anchor]" } else { "" };
let addrs = if rec.addresses.is_empty() {
String::from("(mDNS only)")
} else {
rec.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
};
let intro = if let Some(ib) = rec.introduced_by {
let ib_name = node.get_display_name(&ib).await.unwrap_or(None);
format!(
" via {}",
ib_name.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
)
} else {
String::new()
};
println!(" {}{} [{}]{}", label, anchor, addrs, intro);
}
}
Err(e) => println!("Error: {}", e),
},
"name" => {
if let Some(display_name) = arg {
match node.set_profile(display_name.to_string(), String::new()).await {
Ok(profile) => {
println!("Display name set to: {}", profile.display_name);
}
Err(e) => println!("Error: {}", e),
}
} else {
// Show current name
match node.my_profile().await {
Ok(Some(profile)) => println!("Your name: {}", profile.display_name),
Ok(None) => println!("No display name set. Usage: name <display_name>"),
Err(e) => println!("Error: {}", e),
}
}
}
"stats" => match node.stats().await {
Ok(stats) => {
println!("Posts: {}", stats.post_count);
println!("Peers: {}", stats.peer_count);
println!("Following: {}", stats.follow_count);
}
Err(e) => println!("Error: {}", e),
},
"export-key" => {
match node.export_identity_hex() {
Ok(hex_key) => {
println!("WARNING: This is your SECRET identity key. Anyone with");
println!("this key can impersonate you. Keep it safe!");
println!();
println!("{}", hex_key);
println!();
println!("To import on another device, use:");
println!(" itsgoin <data_dir> --import-key {}", hex_key);
}
Err(e) => println!("Error: {}", e),
}
}
"id" => {
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
println!("Node ID: {}", node_id_hex);
for sock in &sockets {
if let Some(first_sock) = sockets.first() {
println!("Connect: {}@{}", node_id_hex, first_sock);
break;
}
println!("Listen: {}", sock);
}
}
"audience" => {
match node.list_audience_members().await {
Ok(members) => {
if members.is_empty() {
println!("(no audience members)");
} else {
println!("Audience members ({}):", members.len());
for nid in members {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let label = name.unwrap_or_else(|| hex::encode(&nid)[..12].to_string());
println!(" {}", label);
}
}
}
Err(e) => println!("Error: {}", e),
}
}
"audience-request" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
match node.request_audience(&nid).await {
Ok(()) => println!("Audience request sent"),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: audience-request <node_id_hex>");
}
}
"audience-pending" => {
use itsgoin_core::types::{AudienceDirection, AudienceStatus};
match node.list_audience(AudienceDirection::Inbound, Some(AudienceStatus::Pending)).await {
Ok(records) => {
if records.is_empty() {
println!("(no pending audience requests)");
} else {
println!("Pending audience requests ({}):", records.len());
for rec in records {
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
let label = name.unwrap_or_else(|| hex::encode(&rec.node_id)[..12].to_string());
println!(" {}", label);
}
}
}
Err(e) => println!("Error: {}", e),
}
}
"audience-approve" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
match node.approve_audience(&nid).await {
Ok(()) => println!("Approved audience member"),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: audience-approve <node_id_hex>");
}
}
"audience-remove" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
match node.remove_audience(&nid).await {
Ok(()) => println!("Removed from audience"),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: audience-remove <node_id_hex>");
}
}
"worm" => {
if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) {
Ok(nid) => {
println!("Worm lookup for {}...", &id_hex[..16.min(id_hex.len())]);
let start = std::time::Instant::now();
match node.worm_lookup(&nid).await {
Ok(Some(wr)) => {
let elapsed = start.elapsed();
if wr.node_id == nid {
println!("Found! ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
} else {
println!("Found via recent peer {}! ({:.1}ms)",
&hex::encode(wr.node_id)[..12],
elapsed.as_secs_f64() * 1000.0);
}
if wr.addresses.is_empty() {
println!(" (no address resolved)");
} else {
for addr in &wr.addresses {
println!(" Address: {}", addr);
}
}
println!(" Reporter: {}", &hex::encode(wr.reporter)[..12]);
}
Ok(None) => {
let elapsed = start.elapsed();
println!("Not found ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
}
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid node ID: {}", e),
}
} else {
println!("Usage: worm <node_id_hex>");
}
}
"connections" => {
let conns = node.list_connections().await;
if conns.is_empty() {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot_kind, connected_at) in conns {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_short = &hex::encode(nid)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
.unwrap_or_else(|| hex::encode(nid));
let duration_secs = {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
}
}
}
"social-routes" => {
match node.list_social_routes().await {
Ok(routes) => {
if routes.is_empty() {
println!("(no social routes)");
} else {
println!("Social routes ({}):", routes.len());
for r in routes {
let name = node.get_display_name(&r.node_id).await.unwrap_or(None);
let id_short = &hex::encode(r.node_id)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
.unwrap_or_else(|| hex::encode(r.node_id));
let addrs = if r.addresses.is_empty() {
String::from("(no addr)")
} else {
r.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
};
println!(" {} [{:?}] {} [{}] via {:?} peers:{}",
label, r.relation, r.status, addrs,
r.reach_method, r.peer_addresses.len());
}
}
}
Err(e) => println!("Error: {}", e),
}
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
break;
}
_ => {
println!("Unknown command: {}", cmd);
}
}
print!("> ");
io::stdout().flush()?;
}
Ok(())
}
async fn print_post(
id: &[u8; 32],
post: &itsgoin_core::types::Post,
vis: &itsgoin_core::types::PostVisibility,
decrypted: Option<&str>,
node: &Node,
) {
let author_hex = hex::encode(post.author);
let author_short = &author_hex[..12];
let is_me = &post.author == &node.node_id;
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
let author_label = match (author_name, is_me) {
(Some(name), true) => format!("{} (you)", name),
(Some(name), false) => name,
(None, true) => format!("{} (you)", author_short),
(None, false) => author_short.to_string(),
};
let vis_label = match vis {
itsgoin_core::types::PostVisibility::Public => String::new(),
itsgoin_core::types::PostVisibility::Encrypted { recipients } => {
format!(" [encrypted, {} recipients]", recipients.len())
}
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
format!(" [group-encrypted, epoch {}]", epoch)
}
};
let ts = post.timestamp_ms / 1000;
let datetime = chrono_lite(ts);
let display_content = match vis {
itsgoin_core::types::PostVisibility::Public => post.content.as_str().to_string(),
itsgoin_core::types::PostVisibility::Encrypted { .. }
| itsgoin_core::types::PostVisibility::GroupEncrypted { .. } => match decrypted {
Some(text) => text.to_string(),
None => "(encrypted)".to_string(),
},
};
println!("---");
println!(" {} | {}{}", author_label, datetime, vis_label);
println!(" {}", display_content);
println!(" id: {}", &hex::encode(id)[..16]);
}
/// Minimal timestamp formatting without pulling in chrono
fn chrono_lite(unix_secs: u64) -> String {
let secs_per_min = 60u64;
let secs_per_hour = 3600u64;
let secs_per_day = 86400u64;
let days_since_epoch = unix_secs / secs_per_day;
let time_of_day = unix_secs % secs_per_day;
let hours = time_of_day / secs_per_hour;
let minutes = (time_of_day % secs_per_hour) / secs_per_min;
let mut year = 1970u64;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let month_days: Vec<u64> = if is_leap(year) {
vec![31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
vec![31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for &d in &month_days {
if remaining_days < d {
break;
}
remaining_days -= d;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
year, month, day, hours, minutes
)
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}