Phase 2c: remove audience + PostPush + PostNotification + AudienceRequest/Response

v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.

Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
  0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
  deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
  functions targeting audience are deleted; the existing handlers stay
  (both already dead code on the send side).

Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
  `push_disconnect_to_audience`, `push_address_update_to_audience`,
  `send_audience_request`, `send_audience_response`, `send_to_audience` —
  all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
  `remove_audience`, `list_audience_members`, `list_audience` removed from
  Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
  `AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
  `remove_audience`, `row_to_audience_record`, `audience_crud` test, the
  `audience` CREATE TABLE, and the audience-dependent social route rebuild
  branch all removed. Upgraded DBs retain the orphan `audience` table;
  nothing touches it.

Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
  The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
  pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
  is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
  author's public follows (`list_public_follows`) rather than a separate
  audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
  unfollow removes the social route entirely.

UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
  `request_audience`, `approve_audience`, `remove_audience` commands
  removed from invoke_handler. Frontend: audience panel and audience/mutual
  badges removed; compose permission dropdown shows "Followers" instead of
  "Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.

Tests: 111 / 111 core tests pass.

Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
This commit is contained in:
Scott Reimers 2026-04-22 22:20:02 -04:00
parent 36b6a466d2
commit eabdb7ba4f
10 changed files with 98 additions and 1140 deletions

View file

@ -138,11 +138,6 @@ async fn main() -> anyhow::Result<()> {
println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)"); println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)");
println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node"); println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node");
println!(" redundancy Show replica counts for your posts"); 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!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections"); println!(" connections Show mesh connections");
println!(" social-routes Show social routing cache"); println!(" social-routes Show social routing cache");
@ -713,91 +708,6 @@ async fn main() -> anyhow::Result<()> {
} }
} }
"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" => { "worm" => {
if let Some(id_hex) = arg { if let Some(id_hex) = arg {
match itsgoin_core::parse_node_id_hex(id_hex) { match itsgoin_core::parse_node_id_hex(id_hex) {

View file

@ -13,12 +13,11 @@ use crate::crypto;
use crate::protocol::{ use crate::protocol::{
read_message_type, read_payload, write_typed_message, AnchorReferral, read_message_type, read_payload, write_typed_message, AnchorReferral,
AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload,
AudienceRequestPayload, AudienceResponsePayload, BlobHeaderDiffPayload, BlobHeaderDiffPayload,
BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload,
CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload, CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload,
GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload,
MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload,
PostNotificationPayload, PostPushPayload,
ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload,
RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload,
SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload,
@ -1894,138 +1893,6 @@ impl ConnectionManager {
sent sent
} }
/// Handle an incoming post notification: if we follow the author, pull the post.
/// `conn` is a fallback connection for ephemeral callers (not persistently connected).
pub async fn handle_post_notification(
&self,
from: &NodeId,
notification: PostNotificationPayload,
conn: Option<&iroh::endpoint::Connection>,
) -> anyhow::Result<bool> {
let dominated = {
let storage = self.storage.get().await;
// Already have this post?
if storage.get_post(&notification.post_id)?.is_some() {
return Ok(false);
}
// Do we follow the author?
let follows = storage.list_follows()?;
follows.contains(&notification.author)
};
if !dominated {
return Ok(false);
}
// We follow the author and don't have the post — pull it from the notifier
let pull_conn = match self.connections.get(from) {
Some(pc) => pc.connection.clone(),
None => match conn {
Some(c) => c.clone(),
None => return Ok(false),
},
};
let (our_follows, follows_sync, our_personas) = {
let storage = self.storage.get().await;
(
storage.list_follows()?,
storage.get_follows_with_last_sync().unwrap_or_default(),
storage.list_posting_identities().unwrap_or_default(),
)
};
// Merged pull: include every posting identity so DMs match recipient.
let mut query_list = our_follows;
for pi in &our_personas {
if !query_list.contains(&pi.node_id) {
query_list.push(pi.node_id);
}
}
let (mut send, mut recv) = pull_conn.open_bi().await?;
let request = PullSyncRequestPayload {
follows: query_list,
have_post_ids: vec![], // v4: empty, using since_ms instead
since_ms: follows_sync,
};
write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?;
send.finish()?;
let _resp_type = read_message_type(&mut recv).await?;
let response: PullSyncResponsePayload =
read_payload(&mut recv, MAX_PAYLOAD).await?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut stored = false;
let mut new_post_ids: Vec<PostId> = Vec::new();
let mut synced_authors: HashSet<NodeId> = HashSet::new();
// Brief lock 1: store posts
{
let storage = self.storage.get().await;
for sp in &response.posts {
if verify_post_id(&sp.id, &sp.post) && !storage.is_deleted(&sp.id)? {
match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) {
Ok(_) => {
new_post_ids.push(sp.id);
synced_authors.insert(sp.post.author);
if sp.id == notification.post_id {
stored = true;
}
}
Err(e) => {
warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post");
}
}
}
}
}
// Lock RELEASED
// Brief lock 2: upstream + last_sync + visibility updates
{
let storage = self.storage.get().await;
for pid in &new_post_ids {
let _ = storage.touch_file_holder(
pid,
from,
&[],
crate::storage::HolderDirection::Received,
);
}
for author in &synced_authors {
let _ = storage.update_follow_last_sync(author, now_ms);
}
for vu in &response.visibility_updates {
if let Some(post) = storage.get_post(&vu.post_id)? {
if post.author == vu.author {
let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility);
}
}
}
}
// Register as downstream for new posts (cap at 50 to avoid flooding)
if !new_post_ids.is_empty() {
let reg_conn = pull_conn.clone();
tokio::spawn(async move {
for post_id in new_post_ids.into_iter().take(50) {
let payload = PostDownstreamRegisterPayload { post_id };
if let Ok(mut send) = reg_conn.open_uni().await {
let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await;
let _ = send.finish();
}
}
});
}
Ok(stored)
}
/// Pull posts from a connected peer. /// Pull posts from a connected peer.
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullSyncStats> { pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullSyncStats> {
let pc = self let pc = self
@ -4987,110 +4854,6 @@ impl ConnectionManager {
} }
} }
} }
MessageType::PostNotification => {
let notification: PostNotificationPayload =
read_payload(recv, MAX_PAYLOAD).await?;
info!(
peer = hex::encode(remote_node_id),
post_id = hex::encode(notification.post_id),
author = hex::encode(notification.author),
"Received post notification"
);
let cm = conn_mgr.lock().await;
match cm.handle_post_notification(&remote_node_id, notification, None).await {
Ok(true) => {
info!(peer = hex::encode(remote_node_id), "Pulled post from notification");
}
Ok(false) => {
info!(peer = hex::encode(remote_node_id), "Post notification ignored (not following or already have)");
}
Err(e) => {
warn!(peer = hex::encode(remote_node_id), error = %e, "Post notification pull failed");
}
}
}
MessageType::PostPush => {
let push: PostPushPayload = read_payload(recv, MAX_PAYLOAD).await?;
// Encrypted posts are no longer accepted via direct push — they propagate
// via the CDN to eliminate the sender→recipient traffic signal.
if !matches!(push.post.visibility, crate::types::PostVisibility::Public) {
debug!(
peer = hex::encode(remote_node_id),
post_id = hex::encode(push.post.id),
"Ignoring non-public PostPush"
);
} else {
let cm = conn_mgr.lock().await;
let storage = cm.storage.get().await;
if !storage.is_deleted(&push.post.id)?
&& storage.get_post(&push.post.id)?.is_none()
&& crate::content::verify_post_id(&push.post.id, &push.post.post)
{
match crate::control::receive_post(
&storage,
&push.post.id,
&push.post.post,
&push.post.visibility,
push.post.intent.as_ref(),
) {
Ok(_) => {
let _ = storage.touch_file_holder(
&push.post.id,
&remote_node_id,
&[],
crate::storage::HolderDirection::Received,
);
info!(
peer = hex::encode(remote_node_id),
post_id = hex::encode(push.post.id),
"Received direct post push"
);
}
Err(e) => {
warn!(post_id = hex::encode(push.post.id), error = %e, "rejecting pushed post");
}
}
}
}
}
MessageType::AudienceRequest => {
let req: AudienceRequestPayload = read_payload(recv, MAX_PAYLOAD).await?;
info!(
peer = hex::encode(remote_node_id),
requester = hex::encode(req.requester),
"Received audience request"
);
let cm = conn_mgr.lock().await;
let storage = cm.storage.get().await;
// Store as inbound pending request
let _ = storage.store_audience(
&req.requester,
crate::types::AudienceDirection::Inbound,
crate::types::AudienceStatus::Pending,
);
}
MessageType::AudienceResponse => {
let resp: AudienceResponsePayload = read_payload(recv, MAX_PAYLOAD).await?;
let status = if resp.approved { "approved" } else { "denied" };
info!(
peer = hex::encode(remote_node_id),
responder = hex::encode(resp.responder),
status,
"Received audience response"
);
let cm = conn_mgr.lock().await;
let storage = cm.storage.get().await;
let new_status = if resp.approved {
crate::types::AudienceStatus::Approved
} else {
crate::types::AudienceStatus::Denied
};
let _ = storage.store_audience(
&resp.responder,
crate::types::AudienceDirection::Outbound,
new_status,
);
}
MessageType::SocialAddressUpdate => { MessageType::SocialAddressUpdate => {
let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
let cm = conn_mgr.lock().await; let cm = conn_mgr.lock().await;
@ -6280,18 +6043,18 @@ impl ConnectionManager {
async fn handle_blob_header_diff(&self, payload: BlobHeaderDiffPayload, sender: NodeId) { async fn handle_blob_header_diff(&self, payload: BlobHeaderDiffPayload, sender: NodeId) {
use crate::types::BlobHeaderDiffOp; use crate::types::BlobHeaderDiffOp;
// Gather policy + audience data + holders, then drop lock immediately. // Gather policy + followers set + holders, then drop lock immediately.
// Remote peer clearly holds this post — record them as a holder. // Remote peer clearly holds this post — record them as a holder.
let (policy, approved_audience, holders) = { // v0.6.2: `AudienceOnly` → `FollowersOnly`; checked against our public
// follows list rather than a separate audience table.
let (policy, followers_set, holders) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let policy = storage.get_comment_policy(&payload.post_id) let policy = storage.get_comment_policy(&payload.post_id)
.ok() .ok()
.flatten() .flatten()
.unwrap_or_default(); .unwrap_or_default();
let approved = storage.list_audience( let follows: std::collections::HashSet<NodeId> =
crate::types::AudienceDirection::Inbound, storage.list_public_follows().unwrap_or_default().into_iter().collect();
Some(crate::types::AudienceStatus::Approved),
).unwrap_or_default();
let _ = storage.touch_file_holder( let _ = storage.touch_file_holder(
&payload.post_id, &payload.post_id,
&sender, &sender,
@ -6303,12 +6066,9 @@ impl ConnectionManager {
.into_iter() .into_iter()
.map(|(nid, _addrs)| nid) .map(|(nid, _addrs)| nid)
.collect(); .collect();
(policy, approved, holders) (policy, follows, holders)
}; };
// Filter ops using gathered data (no lock held)
let audience_set: std::collections::HashSet<NodeId> = approved_audience.iter().map(|a| a.node_id).collect();
// Apply ops in a short lock acquisition // Apply ops in a short lock acquisition
{ {
let storage = self.storage.get().await; let storage = self.storage.get().await;
@ -6344,8 +6104,8 @@ impl ConnectionManager {
} }
match policy.allow_comments { match policy.allow_comments {
crate::types::CommentPermission::None => continue, crate::types::CommentPermission::None => continue,
crate::types::CommentPermission::AudienceOnly => { crate::types::CommentPermission::FollowersOnly => {
if !audience_set.contains(&comment.author) { if !followers_set.contains(&comment.author) {
continue; continue;
} }
} }

View file

@ -12,15 +12,14 @@ use crate::blob::BlobStore;
use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult}; use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult};
use crate::content::verify_post_id; use crate::content::verify_post_id;
use crate::protocol::{ use crate::protocol::{
read_message_type, read_payload, write_typed_message, AudienceRequestPayload, read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload,
AudienceResponsePayload, BlobRequestPayload, BlobResponsePayload, DeleteRecordPayload, MessageType, ProfileUpdatePayload,
MessageType, PostNotificationPayload, PostPushPayload, ProfileUpdatePayload,
PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload,
SocialAddressUpdatePayload, SocialDisconnectNoticePayload, SyncPost, ALPN_V2, ALPN_V2,
}; };
use crate::storage::StoragePool; use crate::storage::StoragePool;
use crate::types::{ use crate::types::{
DeleteRecord, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, PeerWithAddress, Post, PostId, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId,
PostVisibility, PublicProfile, SessionReachMethod, WormResult, PostVisibility, PublicProfile, SessionReachMethod, WormResult,
}; };
@ -893,16 +892,7 @@ impl Network {
Ok(sent) Ok(sent)
} }
/// Send a post notification to all audience members (ephemeral-capable). /// Push a profile update to all audience members (ephemeral-capable).
pub async fn notify_post(&self, post_id: &crate::types::PostId, author: &NodeId) -> usize {
let payload = PostNotificationPayload {
post_id: *post_id,
author: *author,
};
self.send_to_audience(MessageType::PostNotification, &payload).await
}
/// Push a profile update to all audience members (ephemeral-capable).
pub async fn push_profile(&self, profile: &PublicProfile) -> usize { pub async fn push_profile(&self, profile: &PublicProfile) -> usize {
// v0.6.1: profiles broadcast on the wire are keyed by the network // v0.6.1: profiles broadcast on the wire are keyed by the network
// NodeId. They carry ONLY routing metadata (anchors, recent_peers, // NodeId. They carry ONLY routing metadata (anchors, recent_peers,
@ -959,38 +949,7 @@ impl Network {
sent sent
} }
/// Push a delete record to all audience members (ephemeral-capable). /// Push a visibility update to all connected peers.
pub async fn push_delete(&self, record: &DeleteRecord) -> usize {
let payload = DeleteRecordPayload {
records: vec![record.clone()],
};
self.send_to_audience(MessageType::DeleteRecord, &payload).await
}
/// Push a disconnect notice to all audience members (ephemeral-capable).
pub async fn push_disconnect_to_audience(&self, disconnected_peer: &NodeId) -> usize {
let payload = SocialDisconnectNoticePayload {
node_id: *disconnected_peer,
};
self.send_to_audience(MessageType::SocialDisconnectNotice, &payload).await
}
/// Push a social address update to all audience members (ephemeral-capable).
pub async fn push_address_update_to_audience(
&self,
node_id: &NodeId,
addresses: &[String],
peer_addresses: &[PeerWithAddress],
) -> usize {
let payload = SocialAddressUpdatePayload {
node_id: *node_id,
addresses: addresses.to_vec(),
peer_addresses: peer_addresses.to_vec(),
};
self.send_to_audience(MessageType::SocialAddressUpdate, &payload).await
}
/// Push a visibility update to all connected peers.
/// Gets connections snapshot, sends I/O outside the lock. /// Gets connections snapshot, sends I/O outside the lock.
pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize { pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize {
use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType}; use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType};
@ -1074,61 +1033,7 @@ impl Network {
} }
} }
/// Send an audience request to a peer (persistent if available, ephemeral otherwise). /// Push a group key to a specific peer (uni-stream).
pub async fn send_audience_request(&self, target: &NodeId) -> anyhow::Result<()> {
let payload = AudienceRequestPayload {
requester: self.our_node_id,
};
self.send_to_peer_uni(target, MessageType::AudienceRequest, &payload).await
}
/// Send an audience response to a peer (persistent if available, ephemeral otherwise).
pub async fn send_audience_response(&self, target: &NodeId, approved: bool) -> anyhow::Result<()> {
let payload = AudienceResponsePayload {
responder: self.our_node_id,
approved,
};
self.send_to_peer_uni(target, MessageType::AudienceResponse, &payload).await
}
/// Push a public post to audience members (persistent if available, ephemeral otherwise).
pub async fn push_to_audience(
&self,
post_id: &crate::types::PostId,
post: &Post,
visibility: &PostVisibility,
) -> usize {
if !matches!(visibility, PostVisibility::Public) {
return 0;
}
let audience_members: Vec<NodeId> = {
match self.storage.get().await.list_audience_members() {
Ok(m) => m,
Err(_) => return 0,
}
};
let payload = PostPushPayload {
post: SyncPost {
id: *post_id,
post: post.clone(),
visibility: visibility.clone(),
intent: None, // PostPush is only for public posts; no intent carried
},
};
let mut pushed = 0;
for member in &audience_members {
if self.send_to_peer_uni(member, MessageType::PostPush, &payload).await.is_ok() {
pushed += 1;
}
}
pushed
}
/// Push a group key to a specific peer (uni-stream).
pub async fn push_group_key( pub async fn push_group_key(
&self, &self,
peer: &NodeId, peer: &NodeId,
@ -1672,24 +1577,7 @@ impl Network {
} }
} }
// ---- Audience-targeted + ephemeral helpers ---- /// Pull posts from a peer (persistent if available, ephemeral otherwise).
/// Send a uni-stream message to all audience members (persistent if available, ephemeral otherwise).
async fn send_to_audience<T: Serialize>(&self, msg_type: MessageType, payload: &T) -> usize {
let audience: Vec<NodeId> = match self.storage.get().await.list_audience_members() {
Ok(m) => m,
Err(_) => return 0,
};
let mut sent = 0;
for member in &audience {
if self.send_to_peer_uni(member, msg_type, payload).await.is_ok() {
sent += 1;
}
}
sent
}
/// Pull posts from a peer (persistent if available, ephemeral otherwise).
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> { pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> {
let conn = self.get_connection(peer_id).await?; let conn = self.get_connection(peer_id).await?;
let (our_follows, follows_sync, our_personas) = { let (our_follows, follows_sync, our_personas) = {

View file

@ -12,10 +12,10 @@ use crate::crypto;
use crate::network::Network; use crate::network::Network;
use crate::storage::StoragePool; use crate::storage::StoragePool;
use crate::types::{ use crate::types::{
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, DeleteRecord, Attachment, Circle,
DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId, DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId,
PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation, PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation,
SocialRouteEntry, SocialStatus, VisibilityIntent, VisibilityUpdate, WormResult, SocialRouteEntry, SocialStatus, VisibilityIntent, WormResult,
}; };
/// Built-in default anchor — always available as a bootstrap fallback. /// Built-in default anchor — always available as a bootstrap fallback.
@ -1002,12 +1002,10 @@ impl Node {
} }
} }
// For public posts, push to audience members. Encrypted posts propagate // v0.6.2: posts propagate ONLY via the CDN (pull + header-diff
// via the CDN (ManifestPush + header-diff) to eliminate the sender→recipient // neighbor propagation). Persona-signed direct pushes (PostPush,
// traffic signal. // PostNotification) are gone — they exposed sender→recipient traffic.
let audience_pushed = self.network.push_to_audience(&post_id, &post, &visibility).await; info!(post_id = hex::encode(post_id), "Created new post");
info!(post_id = hex::encode(post_id), audience_pushed, "Created new post");
Ok((post_id, post, visibility)) Ok((post_id, post, visibility))
} }
@ -1186,9 +1184,7 @@ impl Node {
let storage = self.storage.get().await; let storage = self.storage.get().await;
storage.add_follow(node_id)?; storage.add_follow(node_id)?;
// Upsert social route // Upsert social route. v0.6.2: audience removed; only Follow exists.
let is_audience = storage.list_audience_members()?.contains(node_id);
let relation = if is_audience { SocialRelation::Mutual } else { SocialRelation::Follow };
let addresses = storage.get_peer_record(node_id)? let addresses = storage.get_peer_record(node_id)?
.map(|r| r.addresses).unwrap_or_default(); .map(|r| r.addresses).unwrap_or_default();
let peer_addresses = storage.build_peer_addresses_for(node_id)?; let peer_addresses = storage.build_peer_addresses_for(node_id)?;
@ -1199,7 +1195,7 @@ impl Node {
node_id: *node_id, node_id: *node_id,
addresses, addresses,
peer_addresses, peer_addresses,
relation, relation: SocialRelation::Follow,
status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected }, status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected },
last_connected_ms: 0, last_connected_ms: 0,
last_seen_ms: now, last_seen_ms: now,
@ -1213,19 +1209,8 @@ impl Node {
pub async fn unfollow(&self, node_id: &NodeId) -> anyhow::Result<()> { pub async fn unfollow(&self, node_id: &NodeId) -> anyhow::Result<()> {
let storage = self.storage.get().await; let storage = self.storage.get().await;
storage.remove_follow(node_id)?; storage.remove_follow(node_id)?;
// v0.6.2: audience removed; unfollow drops the social route entirely.
// Downgrade or remove social route storage.remove_social_route(node_id)?;
let is_audience = storage.list_audience_members()?.contains(node_id);
if is_audience {
// Downgrade from Mutual to Audience
if let Some(mut route) = storage.get_social_route(node_id)? {
route.relation = SocialRelation::Audience;
storage.upsert_social_route(&route)?;
}
} else {
storage.remove_social_route(node_id)?;
}
Ok(()) Ok(())
} }
@ -2086,12 +2071,11 @@ impl Node {
let staleness_ms = 3600 * 1000; let staleness_ms = 3600 * 1000;
let (candidates, follows, audience_members) = { let (candidates, follows) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let candidates = storage.get_eviction_candidates(staleness_ms)?; let candidates = storage.get_eviction_candidates(staleness_ms)?;
let follows = storage.list_follows().unwrap_or_default(); let follows = storage.list_follows().unwrap_or_default();
let audience = storage.list_audience_members().unwrap_or_default(); (candidates, follows)
(candidates, follows, audience)
}; };
if candidates.is_empty() { if candidates.is_empty() {
@ -2111,7 +2095,7 @@ impl Node {
let mut min_priority = f64::MAX; let mut min_priority = f64::MAX;
let mut min_created_at = u64::MAX; let mut min_created_at = u64::MAX;
for c in &non_elevated { for c in &non_elevated {
let priority = self.compute_blob_priority(c, &follows, &audience_members, now); let priority = self.compute_blob_priority(c, &follows, now);
if priority < min_priority { if priority < min_priority {
min_priority = priority; min_priority = priority;
min_created_at = c.created_at; min_created_at = c.created_at;
@ -3416,98 +3400,6 @@ impl Node {
storage.list_social_routes() storage.list_social_routes()
} }
// ---- Audience ----
pub async fn request_audience(&self, node_id: &NodeId) -> anyhow::Result<()> {
{
let storage = self.storage.get().await;
storage.store_audience(node_id, AudienceDirection::Outbound, AudienceStatus::Pending)?;
}
// Send the request (persistent if available, ephemeral otherwise)
if let Err(e) = self.network.send_audience_request(node_id).await {
warn!(peer = hex::encode(node_id), error = %e, "Failed to send audience request");
}
info!(peer = hex::encode(node_id), "Requested audience membership");
Ok(())
}
pub async fn approve_audience(&self, node_id: &NodeId) -> anyhow::Result<()> {
let connected = self.network.is_connected(node_id).await;
{
let storage = self.storage.get().await;
storage.store_audience(node_id, AudienceDirection::Inbound, AudienceStatus::Approved)?;
// Upsert social route (Audience or Mutual)
let is_follow = storage.list_follows()?.contains(node_id);
let relation = if is_follow { SocialRelation::Mutual } else { SocialRelation::Audience };
let addresses = storage.get_peer_record(node_id)?
.map(|r| r.addresses).unwrap_or_default();
let peer_addresses = storage.build_peer_addresses_for(node_id)?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default().as_millis() as u64;
let preferred_tree = storage.build_preferred_tree_for(node_id).unwrap_or_default();
storage.upsert_social_route(&SocialRouteEntry {
node_id: *node_id,
addresses,
peer_addresses,
relation,
status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected },
last_connected_ms: 0,
last_seen_ms: now,
reach_method: ReachMethod::Direct,
preferred_tree,
})?;
}
// Send approval response (persistent if available, ephemeral otherwise)
if let Err(e) = self.network.send_audience_response(node_id, true).await {
warn!(peer = hex::encode(node_id), error = %e, "Failed to send audience approval");
}
info!(peer = hex::encode(node_id), "Approved audience request");
Ok(())
}
pub async fn deny_audience(&self, node_id: &NodeId) -> anyhow::Result<()> {
let storage = self.storage.get().await;
storage.store_audience(node_id, AudienceDirection::Inbound, AudienceStatus::Denied)?;
Ok(())
}
pub async fn remove_audience(&self, node_id: &NodeId) -> anyhow::Result<()> {
let storage = self.storage.get().await;
storage.remove_audience(node_id, AudienceDirection::Inbound)?;
// Downgrade or remove social route
let is_follow = storage.list_follows()?.contains(node_id);
if is_follow {
if let Some(mut route) = storage.get_social_route(node_id)? {
route.relation = SocialRelation::Follow;
storage.upsert_social_route(&route)?;
}
} else {
storage.remove_social_route(node_id)?;
}
Ok(())
}
pub async fn list_audience_members(&self) -> anyhow::Result<Vec<NodeId>> {
let storage = self.storage.get().await;
storage.list_audience_members()
}
pub async fn list_audience(
&self,
direction: AudienceDirection,
status: Option<AudienceStatus>,
) -> anyhow::Result<Vec<AudienceRecord>> {
let storage = self.storage.get().await;
storage.list_audience(direction, status)
}
// ---- Blob Eviction ---- // ---- Blob Eviction ----
/// Compute priority score for a blob. Higher score = keep longer. /// Compute priority score for a blob. Higher score = keep longer.
@ -3515,10 +3407,9 @@ impl Node {
&self, &self,
candidate: &crate::storage::EvictionCandidate, candidate: &crate::storage::EvictionCandidate,
follows: &[NodeId], follows: &[NodeId],
audience_members: &[NodeId],
now_ms: u64, now_ms: u64,
) -> f64 { ) -> f64 {
compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, now_ms) compute_blob_priority_standalone(candidate, &self.node_id, follows, now_ms)
} }
/// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote
@ -3552,18 +3443,17 @@ impl Node {
// 1-hour staleness for replica counts // 1-hour staleness for replica counts
let staleness_ms = 3600 * 1000; let staleness_ms = 3600 * 1000;
let (candidates, follows, audience_members) = { let (candidates, follows) = {
let storage = self.storage.get().await; let storage = self.storage.get().await;
let candidates = storage.get_eviction_candidates(staleness_ms)?; let candidates = storage.get_eviction_candidates(staleness_ms)?;
let follows = storage.list_follows().unwrap_or_default(); let follows = storage.list_follows().unwrap_or_default();
let audience = storage.list_audience_members().unwrap_or_default(); (candidates, follows)
(candidates, follows, audience)
}; };
// Score and sort ascending (lowest priority first) // Score and sort ascending (lowest priority first)
let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates
.iter() .iter()
.map(|c| (self.compute_blob_priority(c, &follows, &audience_members, now), c)) .map(|c| (self.compute_blob_priority(c, &follows, now), c))
.collect(); .collect();
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
@ -4455,7 +4345,6 @@ pub fn compute_blob_priority_standalone(
candidate: &crate::storage::EvictionCandidate, candidate: &crate::storage::EvictionCandidate,
our_node_id: &NodeId, our_node_id: &NodeId,
follows: &[NodeId], follows: &[NodeId],
audience_members: &[NodeId],
now_ms: u64, now_ms: u64,
) -> f64 { ) -> f64 {
let pin_boost = if candidate.pinned { 1000.0 } else { 0.0 }; let pin_boost = if candidate.pinned { 1000.0 } else { 0.0 };
@ -4470,14 +4359,11 @@ pub fn compute_blob_priority_standalone(
0.0 0.0
}; };
// v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other.
let relationship = if candidate.author == *our_node_id { let relationship = if candidate.author == *our_node_id {
5.0 5.0
} else if follows.contains(&candidate.author) && audience_members.contains(&candidate.author) {
3.0
} else if follows.contains(&candidate.author) { } else if follows.contains(&candidate.author) {
2.0 2.0
} else if audience_members.contains(&candidate.author) {
1.0
} else { } else {
0.1 0.1
}; };
@ -4669,39 +4555,36 @@ mod tests {
let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let candidate = make_candidate(our_id, true, now - 86400_000, now, 0);
let score = compute_blob_priority_standalone( let score = compute_blob_priority_standalone(
&candidate, &our_id, &[], &[], now, &candidate, &our_id, &[], now,
); );
// Should be 1000 + (5.0 * 1.0 * ~0.5 * 1.0) = ~1002.5
assert!(score > 1000.0, "own pinned should score >1000, got {}", score); assert!(score > 1000.0, "own pinned should score >1000, got {}", score);
} }
#[test] #[test]
fn follow_recent_scores_higher_than_audience_stale() { fn follow_recent_scores_higher_than_stranger_stale() {
let our_id = make_node_id(1); let our_id = make_node_id(1);
let follow_id = make_node_id(2); let follow_id = make_node_id(2);
let audience_id = make_node_id(3); let stranger_id = make_node_id(3);
let now = 10_000_000_000u64; let now = 10_000_000_000u64;
// Follow: recently accessed, 1-day-old post
let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0);
let follow_score = compute_blob_priority_standalone( let follow_score = compute_blob_priority_standalone(
&follow_candidate, &our_id, &[follow_id], &[], now, &follow_candidate, &our_id, &[follow_id], now,
); );
// Audience: stale (20 days no access), 10-day-old post, 5 copies let stranger_candidate = make_candidate(
let audience_candidate = make_candidate( stranger_id, false,
audience_id, false,
now - 10 * 86400_000, now - 10 * 86400_000,
now - 20 * 86400_000, now - 20 * 86400_000,
5, 5,
); );
let audience_score = compute_blob_priority_standalone( let stranger_score = compute_blob_priority_standalone(
&audience_candidate, &our_id, &[], &[audience_id], now, &stranger_candidate, &our_id, &[], now,
); );
assert!(follow_score > audience_score, assert!(follow_score > stranger_score,
"follow recent ({}) should score higher than audience stale ({})", "follow recent ({}) should score higher than stranger stale ({})",
follow_score, audience_score); follow_score, stranger_score);
} }
#[test] #[test]
@ -4710,7 +4593,6 @@ mod tests {
let stranger = make_node_id(99); let stranger = make_node_id(99);
let now = 10_000_000_000u64; let now = 10_000_000_000u64;
// Stale stranger post with many copies
let candidate = make_candidate( let candidate = make_candidate(
stranger, false, stranger, false,
now - 30 * 86400_000, now - 30 * 86400_000,
@ -4718,10 +4600,9 @@ mod tests {
10, 10,
); );
let score = compute_blob_priority_standalone( let score = compute_blob_priority_standalone(
&candidate, &our_id, &[], &[], now, &candidate, &our_id, &[], now,
); );
// 0.1 relationship * 0.0 heart_recency * ~0.03 freshness / 11 = ~0
assert!(score < 0.01, "stranger stale should score near 0, got {}", score); assert!(score < 0.01, "stranger stale should score near 0, got {}", score);
} }
@ -4729,26 +4610,18 @@ mod tests {
fn priority_ordering() { fn priority_ordering() {
let our_id = make_node_id(1); let our_id = make_node_id(1);
let follow_id = make_node_id(2); let follow_id = make_node_id(2);
let audience_id = make_node_id(3);
let stranger_id = make_node_id(4); let stranger_id = make_node_id(4);
let now = 10_000_000_000u64; let now = 10_000_000_000u64;
// Own pinned (highest)
let own = make_candidate(our_id, true, now - 86400_000, now, 0); let own = make_candidate(our_id, true, now - 86400_000, now, 0);
// Follow recent
let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow = make_candidate(follow_id, false, now - 86400_000, now, 0);
// Audience stale
let audience = make_candidate(audience_id, false, now - 10 * 86400_000, now - 20 * 86400_000, 5);
// Stranger
let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10); let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10);
let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], &[audience_id], now); let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], now);
let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], &[audience_id], now); let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], now);
let audience_score = compute_blob_priority_standalone(&audience, &our_id, &[follow_id], &[audience_id], now); let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now);
let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], &[audience_id], now);
assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score); assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score);
assert!(follow_score > audience_score, "follow ({}) > audience ({})", follow_score, audience_score); assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score);
assert!(audience_score > stranger_score, "audience ({}) > stranger ({})", audience_score, stranger_score);
} }
} }

View file

@ -32,10 +32,9 @@ pub enum MessageType {
RefuseRedirect = 0x05, RefuseRedirect = 0x05,
PullSyncRequest = 0x40, PullSyncRequest = 0x40,
PullSyncResponse = 0x41, PullSyncResponse = 0x41,
PostNotification = 0x42, // 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest),
PostPush = 0x43, // 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes
AudienceRequest = 0x44, // are gone. Public posts propagate via the CDN; encrypted posts via pull.
AudienceResponse = 0x45,
ProfileUpdate = 0x50, ProfileUpdate = 0x50,
DeleteRecord = 0x51, DeleteRecord = 0x51,
VisibilityUpdate = 0x52, VisibilityUpdate = 0x52,
@ -90,10 +89,6 @@ impl MessageType {
0x05 => Some(Self::RefuseRedirect), 0x05 => Some(Self::RefuseRedirect),
0x40 => Some(Self::PullSyncRequest), 0x40 => Some(Self::PullSyncRequest),
0x41 => Some(Self::PullSyncResponse), 0x41 => Some(Self::PullSyncResponse),
0x42 => Some(Self::PostNotification),
0x43 => Some(Self::PostPush),
0x44 => Some(Self::AudienceRequest),
0x45 => Some(Self::AudienceResponse),
0x50 => Some(Self::ProfileUpdate), 0x50 => Some(Self::ProfileUpdate),
0x51 => Some(Self::DeleteRecord), 0x51 => Some(Self::DeleteRecord),
0x52 => Some(Self::VisibilityUpdate), 0x52 => Some(Self::VisibilityUpdate),
@ -241,32 +236,6 @@ pub struct VisibilityUpdatePayload {
pub updates: Vec<VisibilityUpdate>, pub updates: Vec<VisibilityUpdate>,
} }
/// Post notification: lightweight push when a new post is created
#[derive(Debug, Serialize, Deserialize)]
pub struct PostNotificationPayload {
pub post_id: PostId,
pub author: NodeId,
}
/// Audience request: ask a peer to join their audience
#[derive(Debug, Serialize, Deserialize)]
pub struct AudienceRequestPayload {
pub requester: NodeId,
}
/// Audience response: approve or deny an audience request
#[derive(Debug, Serialize, Deserialize)]
pub struct AudienceResponsePayload {
pub responder: NodeId,
pub approved: bool,
}
/// Post push: full post content pushed directly to a recipient
#[derive(Debug, Serialize, Deserialize)]
pub struct PostPushPayload {
pub post: SyncPost,
}
/// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address) /// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address)
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct AddressRequestPayload { pub struct AddressRequestPayload {
@ -770,10 +739,6 @@ mod tests {
MessageType::RefuseRedirect, MessageType::RefuseRedirect,
MessageType::PullSyncRequest, MessageType::PullSyncRequest,
MessageType::PullSyncResponse, MessageType::PullSyncResponse,
MessageType::PostNotification,
MessageType::PostPush,
MessageType::AudienceRequest,
MessageType::AudienceResponse,
MessageType::ProfileUpdate, MessageType::ProfileUpdate,
MessageType::DeleteRecord, MessageType::DeleteRecord,
MessageType::VisibilityUpdate, MessageType::VisibilityUpdate,

View file

@ -4,7 +4,7 @@ use std::path::Path;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use crate::types::{ use crate::types::{
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile, Attachment, Circle, CircleProfile,
CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId, CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId,
GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord, GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord,
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity,
@ -212,14 +212,8 @@ impl Storage {
PRIMARY KEY (peer_id, neighbor_id) PRIMARY KEY (peer_id, neighbor_id)
); );
CREATE INDEX IF NOT EXISTS idx_peer_neighbors_neighbor ON peer_neighbors(neighbor_id); CREATE INDEX IF NOT EXISTS idx_peer_neighbors_neighbor ON peer_neighbors(neighbor_id);
CREATE TABLE IF NOT EXISTS audience ( -- v0.6.2: audience table removed. Upgraded DBs still have the
node_id BLOB NOT NULL, -- orphan table; it's untouched by new code. New DBs don't get it.
direction TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
requested_at INTEGER NOT NULL,
approved_at INTEGER,
PRIMARY KEY (node_id, direction)
);
CREATE TABLE IF NOT EXISTS worm_cooldowns ( CREATE TABLE IF NOT EXISTS worm_cooldowns (
target_id BLOB PRIMARY KEY, target_id BLOB PRIMARY KEY,
failed_at INTEGER NOT NULL failed_at INTEGER NOT NULL
@ -2856,111 +2850,6 @@ impl Storage {
Ok(count > 0) Ok(count > 0)
} }
// ---- Audience ----
/// Store an audience relationship.
pub fn store_audience(
&self,
node_id: &NodeId,
direction: AudienceDirection,
status: AudienceStatus,
) -> anyhow::Result<()> {
let now = now_ms();
let dir_str = match direction {
AudienceDirection::Inbound => "inbound",
AudienceDirection::Outbound => "outbound",
};
let status_str = match status {
AudienceStatus::Pending => "pending",
AudienceStatus::Approved => "approved",
AudienceStatus::Denied => "denied",
};
let approved_at = if status == AudienceStatus::Approved {
Some(now)
} else {
None
};
self.conn.execute(
"INSERT INTO audience (node_id, direction, status, requested_at, approved_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(node_id, direction) DO UPDATE SET
status = ?3, approved_at = COALESCE(?5, audience.approved_at)",
params![node_id.as_slice(), dir_str, status_str, now, approved_at],
)?;
Ok(())
}
/// Get audience members by direction and status.
pub fn list_audience(
&self,
direction: AudienceDirection,
status: Option<AudienceStatus>,
) -> anyhow::Result<Vec<AudienceRecord>> {
let dir_str = match direction {
AudienceDirection::Inbound => "inbound",
AudienceDirection::Outbound => "outbound",
};
let (query, bind_status) = match status {
Some(s) => {
let s_str = match s {
AudienceStatus::Pending => "pending",
AudienceStatus::Approved => "approved",
AudienceStatus::Denied => "denied",
};
(
"SELECT node_id, direction, status, requested_at, approved_at FROM audience WHERE direction = ?1 AND status = ?2",
Some(s_str),
)
}
None => (
"SELECT node_id, direction, status, requested_at, approved_at FROM audience WHERE direction = ?1",
None,
),
};
let mut records = Vec::new();
if let Some(status_str) = bind_status {
let mut stmt = self.conn.prepare(query)?;
let mut rows = stmt.query(params![dir_str, status_str])?;
while let Some(row) = rows.next()? {
records.push(row_to_audience_record(row)?);
}
} else {
let mut stmt = self.conn.prepare(query)?;
let mut rows = stmt.query(params![dir_str])?;
while let Some(row) = rows.next()? {
records.push(row_to_audience_record(row)?);
}
}
Ok(records)
}
/// Get approved inbound audience members (nodes we push posts to).
pub fn list_audience_members(&self) -> anyhow::Result<Vec<NodeId>> {
let records = self.list_audience(
AudienceDirection::Inbound,
Some(AudienceStatus::Approved),
)?;
Ok(records.into_iter().map(|r| r.node_id).collect())
}
/// Remove an audience relationship.
pub fn remove_audience(
&self,
node_id: &NodeId,
direction: AudienceDirection,
) -> anyhow::Result<()> {
let dir_str = match direction {
AudienceDirection::Inbound => "inbound",
AudienceDirection::Outbound => "outbound",
};
self.conn.execute(
"DELETE FROM audience WHERE node_id = ?1 AND direction = ?2",
params![node_id.as_slice(), dir_str],
)?;
Ok(())
}
// ---- Reach: N2/N3 ---- // ---- Reach: N2/N3 ----
/// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2). /// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2).
@ -3607,32 +3496,18 @@ impl Storage {
Ok(count > 0) Ok(count > 0)
} }
/// Bulk-populate social_routes from follows + audience + peers. /// Bulk-populate social_routes from follows + peers.
/// Returns the number of routes created/updated. /// Returns the number of routes created/updated.
pub fn rebuild_social_routes(&self) -> anyhow::Result<usize> { pub fn rebuild_social_routes(&self) -> anyhow::Result<usize> {
let now = now_ms() as u64; let now = now_ms() as u64;
let mut count = 0; let mut count = 0;
// Collect follows // v0.6.2: audience removed; social routes are built purely from follows.
let follows: std::collections::HashSet<NodeId> = let follows: std::collections::HashSet<NodeId> =
self.list_follows()?.into_iter().collect(); self.list_follows()?.into_iter().collect();
// Collect approved audience members (inbound = they are in our audience) for nid in follows {
let audience_members: std::collections::HashSet<NodeId> = let relation = SocialRelation::Follow;
self.list_audience_members()?.into_iter().collect();
// Union of all social contacts
let mut all_contacts: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
all_contacts.extend(&follows);
all_contacts.extend(&audience_members);
for nid in all_contacts {
let relation = match (follows.contains(&nid), audience_members.contains(&nid)) {
(true, true) => SocialRelation::Mutual,
(true, false) => SocialRelation::Follow,
(false, true) => SocialRelation::Audience,
(false, false) => continue,
};
// Look up addresses from peers table // Look up addresses from peers table
let addresses: Vec<std::net::SocketAddr> = self let addresses: Vec<std::net::SocketAddr> = self
@ -4900,30 +4775,6 @@ fn now_ms() -> i64 {
.as_millis() as i64 .as_millis() as i64
} }
fn row_to_audience_record(row: &rusqlite::Row) -> anyhow::Result<AudienceRecord> {
let node_id = blob_to_nodeid(row.get(0)?)?;
let dir_str: String = row.get(1)?;
let status_str: String = row.get(2)?;
let requested_at = row.get::<_, i64>(3)? as u64;
let approved_at: Option<i64> = row.get(4)?;
let direction = match dir_str.as_str() {
"inbound" => AudienceDirection::Inbound,
_ => AudienceDirection::Outbound,
};
let status = match status_str.as_str() {
"approved" => AudienceStatus::Approved,
"denied" => AudienceStatus::Denied,
_ => AudienceStatus::Pending,
};
Ok(AudienceRecord {
node_id,
direction,
status,
requested_at,
approved_at: approved_at.map(|v| v as u64),
})
}
fn row_to_peer_record(row: &rusqlite::Row) -> anyhow::Result<PeerRecord> { fn row_to_peer_record(row: &rusqlite::Row) -> anyhow::Result<PeerRecord> {
let node_id = blob_to_nodeid(row.get(0)?)?; let node_id = blob_to_nodeid(row.get(0)?)?;
let addrs_json: String = row.get(1)?; let addrs_json: String = row.get(1)?;
@ -5282,30 +5133,7 @@ mod tests {
assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 0); assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 0);
} }
#[test] // ---- Social routes tests ----
fn audience_crud() {
use crate::types::{AudienceDirection, AudienceStatus};
let s = temp_storage();
let nid = make_node_id(1);
s.store_audience(&nid, AudienceDirection::Inbound, AudienceStatus::Pending).unwrap();
let pending = s.list_audience(AudienceDirection::Inbound, Some(AudienceStatus::Pending)).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].status, AudienceStatus::Pending);
// Approve
s.store_audience(&nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap();
let members = s.list_audience_members().unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0], nid);
// Remove
s.remove_audience(&nid, AudienceDirection::Inbound).unwrap();
let members = s.list_audience_members().unwrap();
assert!(members.is_empty());
}
// ---- Social routes tests ----
#[test] #[test]
fn social_route_crud() { fn social_route_crud() {
@ -5354,28 +5182,21 @@ mod tests {
#[test] #[test]
fn social_route_rebuild() { fn social_route_rebuild() {
use crate::types::{AudienceDirection, AudienceStatus, SocialRelation}; use crate::types::SocialRelation;
let s = temp_storage(); let s = temp_storage();
let follow_nid = make_node_id(1); let follow_a = make_node_id(1);
let audience_nid = make_node_id(2); let follow_b = make_node_id(2);
let mutual_nid = make_node_id(3);
s.add_follow(&follow_nid).unwrap(); s.add_follow(&follow_a).unwrap();
s.add_follow(&mutual_nid).unwrap(); s.add_follow(&follow_b).unwrap();
s.store_audience(&audience_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap();
s.store_audience(&mutual_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap();
let count = s.rebuild_social_routes().unwrap(); let count = s.rebuild_social_routes().unwrap();
assert_eq!(count, 3); assert_eq!(count, 2);
let follow_route = s.get_social_route(&follow_nid).unwrap().unwrap(); let route_a = s.get_social_route(&follow_a).unwrap().unwrap();
assert_eq!(follow_route.relation, SocialRelation::Follow); assert_eq!(route_a.relation, SocialRelation::Follow);
let route_b = s.get_social_route(&follow_b).unwrap().unwrap();
let audience_route = s.get_social_route(&audience_nid).unwrap().unwrap(); assert_eq!(route_b.relation, SocialRelation::Follow);
assert_eq!(audience_route.relation, SocialRelation::Audience);
let mutual_route = s.get_social_route(&mutual_nid).unwrap().unwrap();
assert_eq!(mutual_route.relation, SocialRelation::Mutual);
} }
#[test] #[test]
@ -6252,7 +6073,7 @@ mod tests {
assert!(s.get_comment_policy(&post_id).unwrap().is_none()); assert!(s.get_comment_policy(&post_id).unwrap().is_none());
let policy = CommentPolicy { let policy = CommentPolicy {
allow_comments: CommentPermission::AudienceOnly, allow_comments: CommentPermission::FollowersOnly,
allow_reacts: ReactPermission::Public, allow_reacts: ReactPermission::Public,
moderation: ModerationMode::AuthorBlocklist, moderation: ModerationMode::AuthorBlocklist,
blocklist: vec![make_node_id(99)], blocklist: vec![make_node_id(99)],
@ -6260,7 +6081,7 @@ mod tests {
s.set_comment_policy(&post_id, &policy).unwrap(); s.set_comment_policy(&post_id, &policy).unwrap();
let loaded = s.get_comment_policy(&post_id).unwrap().unwrap(); let loaded = s.get_comment_policy(&post_id).unwrap().unwrap();
assert_eq!(loaded.allow_comments, CommentPermission::AudienceOnly); assert_eq!(loaded.allow_comments, CommentPermission::FollowersOnly);
assert_eq!(loaded.allow_reacts, ReactPermission::Public); assert_eq!(loaded.allow_reacts, ReactPermission::Public);
assert_eq!(loaded.blocklist.len(), 1); assert_eq!(loaded.blocklist.len(), 1);

View file

@ -157,42 +157,6 @@ pub struct WormResult {
pub blob_holder: Option<NodeId>, pub blob_holder: Option<NodeId>,
} }
/// Audience relationship direction
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudienceDirection {
/// They are in our audience (we push to them)
Inbound,
/// We are in their audience (they push to us)
Outbound,
}
/// Audience membership status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudienceStatus {
Pending,
Approved,
Denied,
}
/// An audience membership record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudienceRecord {
pub node_id: NodeId,
pub direction: AudienceDirection,
pub status: AudienceStatus,
pub requested_at: u64,
pub approved_at: Option<u64>,
}
/// Audience approval mode setting
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudienceApprovalMode {
/// Auto-accept all audience join requests
PublicApprove,
/// Queue requests for manual review
ApprovalQueue,
}
// --- Encryption / Circles --- // --- Encryption / Circles ---
/// Circle name (unique per node) /// Circle name (unique per node)
@ -647,20 +611,17 @@ impl std::str::FromStr for ReachMethod {
} }
} }
/// Social relationship type /// Social relationship type. v0.6.2: audience removed; only `Follow` remains.
/// Kept as an enum for forward compatibility (future persona-level relations).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SocialRelation { pub enum SocialRelation {
Follow, Follow,
Audience,
Mutual,
} }
impl std::fmt::Display for SocialRelation { impl std::fmt::Display for SocialRelation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
SocialRelation::Follow => write!(f, "follow"), SocialRelation::Follow => write!(f, "follow"),
SocialRelation::Audience => write!(f, "audience"),
SocialRelation::Mutual => write!(f, "mutual"),
} }
} }
} }
@ -670,8 +631,8 @@ impl std::str::FromStr for SocialRelation {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"follow" => Ok(SocialRelation::Follow), "follow" => Ok(SocialRelation::Follow),
"audience" => Ok(SocialRelation::Audience), // Legacy DB values from v0.6.1 and earlier — map to Follow.
"mutual" => Ok(SocialRelation::Mutual), "audience" | "mutual" => Ok(SocialRelation::Follow),
_ => Err(anyhow::anyhow!("unknown social relation: {}", s)), _ => Err(anyhow::anyhow!("unknown social relation: {}", s)),
} }
} }
@ -822,8 +783,9 @@ pub struct InlineComment {
pub enum CommentPermission { pub enum CommentPermission {
/// Anyone can comment /// Anyone can comment
Public, Public,
/// Only people in author's audience can comment /// Only people the author follows publicly can comment.
AudienceOnly, /// Renamed from `AudienceOnly` in v0.6.2 when audience was removed.
FollowersOnly,
/// Comments disabled /// Comments disabled
None, None,
} }
@ -858,8 +820,9 @@ impl Default for ReactPermission {
pub enum ModerationMode { pub enum ModerationMode {
/// Author maintains a blocklist of users /// Author maintains a blocklist of users
AuthorBlocklist, AuthorBlocklist,
/// Only audience members can engage /// Only people the author follows publicly can engage.
AudienceOnly, /// Renamed from `AudienceOnly` in v0.6.2.
FollowersOnly,
} }
impl Default for ModerationMode { impl Default for ModerationMode {

View file

@ -1356,111 +1356,6 @@ async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchor
Ok(dtos) Ok(dtos)
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AudienceDto {
node_id: String,
display_name: Option<String>,
direction: String,
status: String,
requested_at: u64,
approved_at: Option<u64>,
}
#[tauri::command]
async fn list_audience(state: State<'_, AppNode>) -> Result<Vec<AudienceDto>, String> {
let node = get_node(&state).await;
let records = node
.list_audience(
itsgoin_core::types::AudienceDirection::Inbound,
None,
)
.await
.map_err(|e| e.to_string())?;
let mut dtos = Vec::with_capacity(records.len());
for r in &records {
let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None);
let direction = match r.direction {
itsgoin_core::types::AudienceDirection::Inbound => "inbound",
itsgoin_core::types::AudienceDirection::Outbound => "outbound",
};
let status = match r.status {
itsgoin_core::types::AudienceStatus::Pending => "pending",
itsgoin_core::types::AudienceStatus::Approved => "approved",
itsgoin_core::types::AudienceStatus::Denied => "denied",
};
dtos.push(AudienceDto {
node_id: hex::encode(r.node_id),
display_name,
direction: direction.to_string(),
status: status.to_string(),
requested_at: r.requested_at,
approved_at: r.approved_at,
});
}
Ok(dtos)
}
#[tauri::command]
async fn list_audience_outbound(state: State<'_, AppNode>) -> Result<Vec<AudienceDto>, String> {
let node = get_node(&state).await;
let records = node
.list_audience(
itsgoin_core::types::AudienceDirection::Outbound,
None,
)
.await
.map_err(|e| e.to_string())?;
let mut dtos = Vec::with_capacity(records.len());
for r in &records {
let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None);
let status = match r.status {
itsgoin_core::types::AudienceStatus::Pending => "pending",
itsgoin_core::types::AudienceStatus::Approved => "approved",
itsgoin_core::types::AudienceStatus::Denied => "denied",
};
dtos.push(AudienceDto {
node_id: hex::encode(r.node_id),
display_name,
direction: "outbound".to_string(),
status: status.to_string(),
requested_at: r.requested_at,
approved_at: r.approved_at,
});
}
Ok(dtos)
}
#[tauri::command]
async fn request_audience(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.request_audience(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn approve_audience(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.approve_audience(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn remove_audience(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.remove_audience(&nid).await.map_err(|e| e.to_string())
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct WormResultDto { struct WormResultDto {
@ -2401,7 +2296,7 @@ async fn set_comment_policy(
let node = get_node(&state).await; let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?; let pid = hex_to_postid(&post_id)?;
let comment_perm = match allow_comments.as_str() { let comment_perm = match allow_comments.as_str() {
"audience_only" => itsgoin_core::types::CommentPermission::AudienceOnly, "followers_only" | "audience_only" => itsgoin_core::types::CommentPermission::FollowersOnly,
"none" => itsgoin_core::types::CommentPermission::None, "none" => itsgoin_core::types::CommentPermission::None,
_ => itsgoin_core::types::CommentPermission::Public, _ => itsgoin_core::types::CommentPermission::Public,
}; };
@ -3011,11 +2906,6 @@ pub fn run() {
set_anchors, set_anchors,
list_anchor_peers, list_anchor_peers,
list_known_anchors, list_known_anchors,
list_audience,
list_audience_outbound,
request_audience,
approve_audience,
remove_audience,
list_connections, list_connections,
worm_lookup, worm_lookup,
list_social_routes, list_social_routes,

View file

@ -1422,14 +1422,8 @@ async function loadPeerBios(container) {
async function loadFollows() { async function loadFollows() {
try { try {
const [follows, outbound, inbound] = await Promise.all([ // v0.6.2: audience removed. No more audience/mutual badges or request flow.
invoke('list_follows'), const follows = await invoke('list_follows');
invoke('list_audience_outbound'),
invoke('list_audience'),
]);
const outboundSet = new Set(outbound.map(r => r.nodeId));
const approvedSet = new Set(outbound.filter(r => r.status === 'approved').map(r => r.nodeId));
const inboundApprovedSet = new Set(inbound.filter(r => r.status === 'approved').map(r => r.nodeId));
// Filter out self before rendering // Filter out self before rendering
const others = follows.filter(f => f.nodeId !== myNodeId); const others = follows.filter(f => f.nodeId !== myNodeId);
@ -1443,34 +1437,21 @@ async function loadFollows() {
const label = escapeHtml(peerLabel(f.nodeId, f.displayName)); const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
const isSelf = f.nodeId === myNodeId; const isSelf = f.nodeId === myNodeId;
let audienceBadge = '';
let mutualBadge = '';
let lastSeenHtml = ''; let lastSeenHtml = '';
let actions = ''; let actions = '';
if (isSelf) { if (isSelf) {
actions = '<span class="self-tag">(you)</span>'; actions = '<span class="self-tag">(you)</span>';
} else { } else {
if (inboundApprovedSet.has(f.nodeId)) {
mutualBadge = '<span class="mutual-badge">mutual</span>';
}
if (approvedSet.has(f.nodeId)) {
audienceBadge = '<span class="audience-badge">audience</span>';
} else if (outboundSet.has(f.nodeId)) {
audienceBadge = '<span class="audience-badge pending">requested</span>';
}
if (!f.isOnline && f.lastActivityMs > 0) { if (!f.isOnline && f.lastActivityMs > 0) {
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`; lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
} }
const audienceBtn = !approvedSet.has(f.nodeId) && !outboundSet.has(f.nodeId)
? `<button class="btn btn-ghost btn-sm request-audience-btn" data-node-id="${f.nodeId}">Ask to join audience</button>`
: '';
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`; const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`; const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`; const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
actions = `${audienceBtn} ${syncBtn} ${msgBtn} ${unfollowBtn}`; actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
} }
return `<div class="peer-card" data-node-id="${f.nodeId}"> return `<div class="peer-card" data-node-id="${f.nodeId}">
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a> ${mutualBadge} ${audienceBadge}</div> <div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a></div>
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''} ${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
<div class="peer-card-bio"></div> <div class="peer-card-bio"></div>
<div class="peer-card-actions">${actions}</div> <div class="peer-card-actions">${actions}</div>
@ -1562,22 +1543,6 @@ async function loadFollows() {
}); });
}); });
// Attach audience request handlers
followsList.querySelectorAll('.request-audience-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('request_audience', { nodeIdHex: btn.dataset.nodeId });
toast('Audience request sent!');
loadFollows();
} catch (e) {
toast('Error: ' + e);
} finally {
btn.disabled = false;
}
});
});
// Lazy-load bios // Lazy-load bios
loadPeerBios(followsList); loadPeerBios(followsList);
} }
@ -1702,81 +1667,13 @@ async function loadRedundancy() {
} }
} }
// --- Audience management --- // v0.6.2: audience removed. loadAudience is a no-op kept so existing call
// sites don't break; DOM panels (if still in markup) are hidden.
async function loadAudience() { async function loadAudience() {
try { if (audiencePendingList) audiencePendingList.style.display = 'none';
const records = await invoke('list_audience'); if (audienceApprovedList) audienceApprovedList.style.display = 'none';
const pending = records.filter(r => r.status === 'pending'); const headings = document.querySelectorAll('.audience-section, #audience-section');
const approved = records.filter(r => r.status === 'approved'); headings.forEach(el => { el.style.display = 'none'; });
if (pending.length === 0) {
audiencePendingList.innerHTML = '<p class="empty-hint">No pending requests</p>';
} else {
audiencePendingList.innerHTML = pending.map(r => {
const label = escapeHtml(peerLabel(r.nodeId, r.displayName));
const icon = generateIdenticon(r.nodeId, 18);
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label}</div>
<div class="peer-card-meta"><span>${relativeTime(r.requestedAt)}</span></div>
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm approve-audience-btn" data-node-id="${r.nodeId}">Approve</button>
<button class="btn btn-danger btn-sm deny-audience-btn" data-node-id="${r.nodeId}">Deny</button>
</div>
</div>`;
}).join('');
audiencePendingList.querySelectorAll('.approve-audience-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('approve_audience', { nodeIdHex: btn.dataset.nodeId });
toast('Audience approved');
loadAudience();
} catch (e) { toast('Error: ' + e); }
});
});
audiencePendingList.querySelectorAll('.deny-audience-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId });
toast('Audience denied');
loadAudience();
} catch (e) { toast('Error: ' + e); }
});
});
}
if (approved.length === 0) {
audienceApprovedList.innerHTML = '<p class="empty-hint">No approved audience members</p>';
} else {
audienceApprovedList.innerHTML = approved.map(r => {
const label = escapeHtml(peerLabel(r.nodeId, r.displayName));
const icon = generateIdenticon(r.nodeId, 18);
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label}</div>
<div class="peer-card-meta"><span>Approved ${r.approvedAt ? relativeTime(r.approvedAt) : ''}</span></div>
<div class="peer-card-actions">
<button class="btn btn-danger btn-sm remove-audience-btn" data-node-id="${r.nodeId}">Remove</button>
</div>
</div>`;
}).join('');
audienceApprovedList.querySelectorAll('.remove-audience-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this audience member?')) return;
btn.disabled = true;
try {
await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId });
toast('Audience member removed');
loadAudience();
} catch (e) { toast('Error: ' + e); }
});
});
}
} catch (e) {
audiencePendingList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
} }
// --- Network diagnostics --- // --- Network diagnostics ---

View file

@ -96,7 +96,7 @@
<select id="circle-select" class="hidden"></select> <select id="circle-select" class="hidden"></select>
<select id="comment-perm-select" title="Comment permission"> <select id="comment-perm-select" title="Comment permission">
<option value="public">Comments: All</option> <option value="public">Comments: All</option>
<option value="audience_only">Comments: Audience</option> <option value="followers_only">Comments: Followers</option>
<option value="none">Comments: Off</option> <option value="none">Comments: Off</option>
</select> </select>
<select id="react-perm-select" title="React permission"> <select id="react-perm-select" title="React permission">
@ -132,16 +132,7 @@
</div> </div>
</div> </div>
<div class="section-card"> <div class="section-card" style="display:flex;gap:0.5rem;flex-wrap:wrap">
<h3>Audience</h3>
<p class="empty-hint">People who receive your public posts.</p>
<h4 class="subsection-title">Pending Requests</h4>
<div id="audience-pending-list"></div>
<h4 class="subsection-title">Approved</h4>
<div id="audience-approved-list"></div>
</div>
<div class="section-card" style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button id="share-details-btn" class="btn btn-ghost btn-sm">Share my details</button> <button id="share-details-btn" class="btn btn-ghost btn-sm">Share my details</button>
<button id="connect-toggle" class="btn btn-ghost btn-sm">Add peer manually</button> <button id="connect-toggle" class="btn btn-ghost btn-sm">Add peer manually</button>
<div id="connect-body" class="hidden"> <div id="connect-body" class="hidden">