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:
parent
36b6a466d2
commit
eabdb7ba4f
10 changed files with 98 additions and 1140 deletions
|
|
@ -138,11 +138,6 @@ async fn main() -> anyhow::Result<()> {
|
|||
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");
|
||||
|
|
@ -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" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ use crate::crypto;
|
|||
use crate::protocol::{
|
||||
read_message_type, read_payload, write_typed_message, AnchorReferral,
|
||||
AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload,
|
||||
AudienceRequestPayload, AudienceResponsePayload, BlobHeaderDiffPayload,
|
||||
BlobHeaderDiffPayload,
|
||||
BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload,
|
||||
CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload,
|
||||
GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload,
|
||||
MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload,
|
||||
PostNotificationPayload, PostPushPayload,
|
||||
ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload,
|
||||
RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload,
|
||||
SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload,
|
||||
|
|
@ -1894,138 +1893,6 @@ impl ConnectionManager {
|
|||
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(¬ification.post_id)?.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Do we follow the author?
|
||||
let follows = storage.list_follows()?;
|
||||
follows.contains(¬ification.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.
|
||||
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullSyncStats> {
|
||||
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 => {
|
||||
let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
||||
let cm = conn_mgr.lock().await;
|
||||
|
|
@ -6280,18 +6043,18 @@ impl ConnectionManager {
|
|||
async fn handle_blob_header_diff(&self, payload: BlobHeaderDiffPayload, sender: NodeId) {
|
||||
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.
|
||||
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 policy = storage.get_comment_policy(&payload.post_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let approved = storage.list_audience(
|
||||
crate::types::AudienceDirection::Inbound,
|
||||
Some(crate::types::AudienceStatus::Approved),
|
||||
).unwrap_or_default();
|
||||
let follows: std::collections::HashSet<NodeId> =
|
||||
storage.list_public_follows().unwrap_or_default().into_iter().collect();
|
||||
let _ = storage.touch_file_holder(
|
||||
&payload.post_id,
|
||||
&sender,
|
||||
|
|
@ -6303,12 +6066,9 @@ impl ConnectionManager {
|
|||
.into_iter()
|
||||
.map(|(nid, _addrs)| nid)
|
||||
.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
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
|
|
@ -6344,8 +6104,8 @@ impl ConnectionManager {
|
|||
}
|
||||
match policy.allow_comments {
|
||||
crate::types::CommentPermission::None => continue,
|
||||
crate::types::CommentPermission::AudienceOnly => {
|
||||
if !audience_set.contains(&comment.author) {
|
||||
crate::types::CommentPermission::FollowersOnly => {
|
||||
if !followers_set.contains(&comment.author) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,14 @@ use crate::blob::BlobStore;
|
|||
use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult};
|
||||
use crate::content::verify_post_id;
|
||||
use crate::protocol::{
|
||||
read_message_type, read_payload, write_typed_message, AudienceRequestPayload,
|
||||
AudienceResponsePayload, BlobRequestPayload, BlobResponsePayload, DeleteRecordPayload,
|
||||
MessageType, PostNotificationPayload, PostPushPayload, ProfileUpdatePayload,
|
||||
read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload,
|
||||
MessageType, ProfileUpdatePayload,
|
||||
PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload,
|
||||
SocialAddressUpdatePayload, SocialDisconnectNoticePayload, SyncPost, ALPN_V2,
|
||||
ALPN_V2,
|
||||
};
|
||||
use crate::storage::StoragePool;
|
||||
use crate::types::{
|
||||
DeleteRecord, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, PeerWithAddress, Post, PostId,
|
||||
DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId,
|
||||
PostVisibility, PublicProfile, SessionReachMethod, WormResult,
|
||||
};
|
||||
|
||||
|
|
@ -893,16 +892,7 @@ impl Network {
|
|||
Ok(sent)
|
||||
}
|
||||
|
||||
/// Send a post notification 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).
|
||||
/// Push a profile update to all audience members (ephemeral-capable).
|
||||
pub async fn push_profile(&self, profile: &PublicProfile) -> usize {
|
||||
// v0.6.1: profiles broadcast on the wire are keyed by the network
|
||||
// NodeId. They carry ONLY routing metadata (anchors, recent_peers,
|
||||
|
|
@ -959,38 +949,7 @@ impl Network {
|
|||
sent
|
||||
}
|
||||
|
||||
/// Push a delete record to all audience members (ephemeral-capable).
|
||||
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.
|
||||
/// Push a visibility update to all connected peers.
|
||||
/// Gets connections snapshot, sends I/O outside the lock.
|
||||
pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize {
|
||||
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).
|
||||
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).
|
||||
/// Push a group key to a specific peer (uni-stream).
|
||||
pub async fn push_group_key(
|
||||
&self,
|
||||
peer: &NodeId,
|
||||
|
|
@ -1672,24 +1577,7 @@ impl Network {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Audience-targeted + ephemeral helpers ----
|
||||
|
||||
/// 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).
|
||||
/// Pull posts from a peer (persistent if available, ephemeral otherwise).
|
||||
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> {
|
||||
let conn = self.get_connection(peer_id).await?;
|
||||
let (our_follows, follows_sync, our_personas) = {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use crate::crypto;
|
|||
use crate::network::Network;
|
||||
use crate::storage::StoragePool;
|
||||
use crate::types::{
|
||||
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, DeleteRecord,
|
||||
Attachment, Circle,
|
||||
DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId,
|
||||
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.
|
||||
|
|
@ -1002,12 +1002,10 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
// For public posts, push to audience members. Encrypted posts propagate
|
||||
// via the CDN (ManifestPush + header-diff) to eliminate the sender→recipient
|
||||
// traffic signal.
|
||||
let audience_pushed = self.network.push_to_audience(&post_id, &post, &visibility).await;
|
||||
|
||||
info!(post_id = hex::encode(post_id), audience_pushed, "Created new post");
|
||||
// v0.6.2: posts propagate ONLY via the CDN (pull + header-diff
|
||||
// neighbor propagation). Persona-signed direct pushes (PostPush,
|
||||
// PostNotification) are gone — they exposed sender→recipient traffic.
|
||||
info!(post_id = hex::encode(post_id), "Created new post");
|
||||
Ok((post_id, post, visibility))
|
||||
}
|
||||
|
||||
|
|
@ -1186,9 +1184,7 @@ impl Node {
|
|||
let storage = self.storage.get().await;
|
||||
storage.add_follow(node_id)?;
|
||||
|
||||
// Upsert social route
|
||||
let is_audience = storage.list_audience_members()?.contains(node_id);
|
||||
let relation = if is_audience { SocialRelation::Mutual } else { SocialRelation::Follow };
|
||||
// Upsert social route. v0.6.2: audience removed; only Follow exists.
|
||||
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)?;
|
||||
|
|
@ -1199,7 +1195,7 @@ impl Node {
|
|||
node_id: *node_id,
|
||||
addresses,
|
||||
peer_addresses,
|
||||
relation,
|
||||
relation: SocialRelation::Follow,
|
||||
status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected },
|
||||
last_connected_ms: 0,
|
||||
last_seen_ms: now,
|
||||
|
|
@ -1213,19 +1209,8 @@ impl Node {
|
|||
pub async fn unfollow(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||
let storage = self.storage.get().await;
|
||||
storage.remove_follow(node_id)?;
|
||||
|
||||
// Downgrade or remove social route
|
||||
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)?;
|
||||
}
|
||||
|
||||
// v0.6.2: audience removed; unfollow drops the social route entirely.
|
||||
storage.remove_social_route(node_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2086,12 +2071,11 @@ impl Node {
|
|||
|
||||
let staleness_ms = 3600 * 1000;
|
||||
|
||||
let (candidates, follows, audience_members) = {
|
||||
let (candidates, follows) = {
|
||||
let storage = self.storage.get().await;
|
||||
let candidates = storage.get_eviction_candidates(staleness_ms)?;
|
||||
let follows = storage.list_follows().unwrap_or_default();
|
||||
let audience = storage.list_audience_members().unwrap_or_default();
|
||||
(candidates, follows, audience)
|
||||
(candidates, follows)
|
||||
};
|
||||
|
||||
if candidates.is_empty() {
|
||||
|
|
@ -2111,7 +2095,7 @@ impl Node {
|
|||
let mut min_priority = f64::MAX;
|
||||
let mut min_created_at = u64::MAX;
|
||||
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 {
|
||||
min_priority = priority;
|
||||
min_created_at = c.created_at;
|
||||
|
|
@ -3416,98 +3400,6 @@ impl Node {
|
|||
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 ----
|
||||
|
||||
/// Compute priority score for a blob. Higher score = keep longer.
|
||||
|
|
@ -3515,10 +3407,9 @@ impl Node {
|
|||
&self,
|
||||
candidate: &crate::storage::EvictionCandidate,
|
||||
follows: &[NodeId],
|
||||
audience_members: &[NodeId],
|
||||
now_ms: u64,
|
||||
) -> 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
|
||||
|
|
@ -3552,18 +3443,17 @@ impl Node {
|
|||
// 1-hour staleness for replica counts
|
||||
let staleness_ms = 3600 * 1000;
|
||||
|
||||
let (candidates, follows, audience_members) = {
|
||||
let (candidates, follows) = {
|
||||
let storage = self.storage.get().await;
|
||||
let candidates = storage.get_eviction_candidates(staleness_ms)?;
|
||||
let follows = storage.list_follows().unwrap_or_default();
|
||||
let audience = storage.list_audience_members().unwrap_or_default();
|
||||
(candidates, follows, audience)
|
||||
(candidates, follows)
|
||||
};
|
||||
|
||||
// Score and sort ascending (lowest priority first)
|
||||
let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates
|
||||
.iter()
|
||||
.map(|c| (self.compute_blob_priority(c, &follows, &audience_members, now), c))
|
||||
.map(|c| (self.compute_blob_priority(c, &follows, now), c))
|
||||
.collect();
|
||||
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,
|
||||
our_node_id: &NodeId,
|
||||
follows: &[NodeId],
|
||||
audience_members: &[NodeId],
|
||||
now_ms: u64,
|
||||
) -> f64 {
|
||||
let pin_boost = if candidate.pinned { 1000.0 } else { 0.0 };
|
||||
|
|
@ -4470,14 +4359,11 @@ pub fn compute_blob_priority_standalone(
|
|||
0.0
|
||||
};
|
||||
|
||||
// v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other.
|
||||
let relationship = if candidate.author == *our_node_id {
|
||||
5.0
|
||||
} else if follows.contains(&candidate.author) && audience_members.contains(&candidate.author) {
|
||||
3.0
|
||||
} else if follows.contains(&candidate.author) {
|
||||
2.0
|
||||
} else if audience_members.contains(&candidate.author) {
|
||||
1.0
|
||||
} else {
|
||||
0.1
|
||||
};
|
||||
|
|
@ -4669,39 +4555,36 @@ mod tests {
|
|||
let candidate = make_candidate(our_id, true, now - 86400_000, now, 0);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[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 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;
|
||||
|
||||
// Follow: recently accessed, 1-day-old post
|
||||
let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0);
|
||||
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 audience_candidate = make_candidate(
|
||||
audience_id, false,
|
||||
let stranger_candidate = make_candidate(
|
||||
stranger_id, false,
|
||||
now - 10 * 86400_000,
|
||||
now - 20 * 86400_000,
|
||||
5,
|
||||
);
|
||||
let audience_score = compute_blob_priority_standalone(
|
||||
&audience_candidate, &our_id, &[], &[audience_id], now,
|
||||
let stranger_score = compute_blob_priority_standalone(
|
||||
&stranger_candidate, &our_id, &[], now,
|
||||
);
|
||||
|
||||
assert!(follow_score > audience_score,
|
||||
"follow recent ({}) should score higher than audience stale ({})",
|
||||
follow_score, audience_score);
|
||||
assert!(follow_score > stranger_score,
|
||||
"follow recent ({}) should score higher than stranger stale ({})",
|
||||
follow_score, stranger_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4710,7 +4593,6 @@ mod tests {
|
|||
let stranger = make_node_id(99);
|
||||
let now = 10_000_000_000u64;
|
||||
|
||||
// Stale stranger post with many copies
|
||||
let candidate = make_candidate(
|
||||
stranger, false,
|
||||
now - 30 * 86400_000,
|
||||
|
|
@ -4718,10 +4600,9 @@ mod tests {
|
|||
10,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -4729,26 +4610,18 @@ mod tests {
|
|||
fn priority_ordering() {
|
||||
let our_id = make_node_id(1);
|
||||
let follow_id = make_node_id(2);
|
||||
let audience_id = make_node_id(3);
|
||||
let stranger_id = make_node_id(4);
|
||||
let now = 10_000_000_000u64;
|
||||
|
||||
// Own pinned (highest)
|
||||
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);
|
||||
// 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 own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], &[audience_id], now);
|
||||
let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], &[audience_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], &[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], now);
|
||||
let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now);
|
||||
|
||||
assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score);
|
||||
assert!(follow_score > audience_score, "follow ({}) > audience ({})", follow_score, audience_score);
|
||||
assert!(audience_score > stranger_score, "audience ({}) > stranger ({})", audience_score, stranger_score);
|
||||
assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ pub enum MessageType {
|
|||
RefuseRedirect = 0x05,
|
||||
PullSyncRequest = 0x40,
|
||||
PullSyncResponse = 0x41,
|
||||
PostNotification = 0x42,
|
||||
PostPush = 0x43,
|
||||
AudienceRequest = 0x44,
|
||||
AudienceResponse = 0x45,
|
||||
// 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest),
|
||||
// 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes
|
||||
// are gone. Public posts propagate via the CDN; encrypted posts via pull.
|
||||
ProfileUpdate = 0x50,
|
||||
DeleteRecord = 0x51,
|
||||
VisibilityUpdate = 0x52,
|
||||
|
|
@ -90,10 +89,6 @@ impl MessageType {
|
|||
0x05 => Some(Self::RefuseRedirect),
|
||||
0x40 => Some(Self::PullSyncRequest),
|
||||
0x41 => Some(Self::PullSyncResponse),
|
||||
0x42 => Some(Self::PostNotification),
|
||||
0x43 => Some(Self::PostPush),
|
||||
0x44 => Some(Self::AudienceRequest),
|
||||
0x45 => Some(Self::AudienceResponse),
|
||||
0x50 => Some(Self::ProfileUpdate),
|
||||
0x51 => Some(Self::DeleteRecord),
|
||||
0x52 => Some(Self::VisibilityUpdate),
|
||||
|
|
@ -241,32 +236,6 @@ pub struct VisibilityUpdatePayload {
|
|||
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)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AddressRequestPayload {
|
||||
|
|
@ -770,10 +739,6 @@ mod tests {
|
|||
MessageType::RefuseRedirect,
|
||||
MessageType::PullSyncRequest,
|
||||
MessageType::PullSyncResponse,
|
||||
MessageType::PostNotification,
|
||||
MessageType::PostPush,
|
||||
MessageType::AudienceRequest,
|
||||
MessageType::AudienceResponse,
|
||||
MessageType::ProfileUpdate,
|
||||
MessageType::DeleteRecord,
|
||||
MessageType::VisibilityUpdate,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::path::Path;
|
|||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::types::{
|
||||
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile,
|
||||
Attachment, Circle, CircleProfile,
|
||||
CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId,
|
||||
GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord,
|
||||
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity,
|
||||
|
|
@ -212,14 +212,8 @@ impl Storage {
|
|||
PRIMARY KEY (peer_id, neighbor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_peer_neighbors_neighbor ON peer_neighbors(neighbor_id);
|
||||
CREATE TABLE IF NOT EXISTS audience (
|
||||
node_id BLOB NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requested_at INTEGER NOT NULL,
|
||||
approved_at INTEGER,
|
||||
PRIMARY KEY (node_id, direction)
|
||||
);
|
||||
-- v0.6.2: audience table removed. Upgraded DBs still have the
|
||||
-- orphan table; it's untouched by new code. New DBs don't get it.
|
||||
CREATE TABLE IF NOT EXISTS worm_cooldowns (
|
||||
target_id BLOB PRIMARY KEY,
|
||||
failed_at INTEGER NOT NULL
|
||||
|
|
@ -2856,111 +2850,6 @@ impl Storage {
|
|||
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 ----
|
||||
|
||||
/// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2).
|
||||
|
|
@ -3607,32 +3496,18 @@ impl Storage {
|
|||
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.
|
||||
pub fn rebuild_social_routes(&self) -> anyhow::Result<usize> {
|
||||
let now = now_ms() as u64;
|
||||
let mut count = 0;
|
||||
|
||||
// Collect follows
|
||||
// v0.6.2: audience removed; social routes are built purely from follows.
|
||||
let follows: std::collections::HashSet<NodeId> =
|
||||
self.list_follows()?.into_iter().collect();
|
||||
|
||||
// Collect approved audience members (inbound = they are in our audience)
|
||||
let audience_members: std::collections::HashSet<NodeId> =
|
||||
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,
|
||||
};
|
||||
for nid in follows {
|
||||
let relation = SocialRelation::Follow;
|
||||
|
||||
// Look up addresses from peers table
|
||||
let addresses: Vec<std::net::SocketAddr> = self
|
||||
|
|
@ -4900,30 +4775,6 @@ fn now_ms() -> 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> {
|
||||
let node_id = blob_to_nodeid(row.get(0)?)?;
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 ----
|
||||
// ---- Social routes tests ----
|
||||
|
||||
#[test]
|
||||
fn social_route_crud() {
|
||||
|
|
@ -5354,28 +5182,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn social_route_rebuild() {
|
||||
use crate::types::{AudienceDirection, AudienceStatus, SocialRelation};
|
||||
use crate::types::SocialRelation;
|
||||
let s = temp_storage();
|
||||
let follow_nid = make_node_id(1);
|
||||
let audience_nid = make_node_id(2);
|
||||
let mutual_nid = make_node_id(3);
|
||||
let follow_a = make_node_id(1);
|
||||
let follow_b = make_node_id(2);
|
||||
|
||||
s.add_follow(&follow_nid).unwrap();
|
||||
s.add_follow(&mutual_nid).unwrap();
|
||||
s.store_audience(&audience_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap();
|
||||
s.store_audience(&mutual_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap();
|
||||
s.add_follow(&follow_a).unwrap();
|
||||
s.add_follow(&follow_b).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();
|
||||
assert_eq!(follow_route.relation, SocialRelation::Follow);
|
||||
|
||||
let audience_route = s.get_social_route(&audience_nid).unwrap().unwrap();
|
||||
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);
|
||||
let route_a = s.get_social_route(&follow_a).unwrap().unwrap();
|
||||
assert_eq!(route_a.relation, SocialRelation::Follow);
|
||||
let route_b = s.get_social_route(&follow_b).unwrap().unwrap();
|
||||
assert_eq!(route_b.relation, SocialRelation::Follow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -6252,7 +6073,7 @@ mod tests {
|
|||
assert!(s.get_comment_policy(&post_id).unwrap().is_none());
|
||||
|
||||
let policy = CommentPolicy {
|
||||
allow_comments: CommentPermission::AudienceOnly,
|
||||
allow_comments: CommentPermission::FollowersOnly,
|
||||
allow_reacts: ReactPermission::Public,
|
||||
moderation: ModerationMode::AuthorBlocklist,
|
||||
blocklist: vec![make_node_id(99)],
|
||||
|
|
@ -6260,7 +6081,7 @@ mod tests {
|
|||
s.set_comment_policy(&post_id, &policy).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.blocklist.len(), 1);
|
||||
|
||||
|
|
|
|||
|
|
@ -157,42 +157,6 @@ pub struct WormResult {
|
|||
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 ---
|
||||
|
||||
/// 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)]
|
||||
pub enum SocialRelation {
|
||||
Follow,
|
||||
Audience,
|
||||
Mutual,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SocialRelation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
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> {
|
||||
match s {
|
||||
"follow" => Ok(SocialRelation::Follow),
|
||||
"audience" => Ok(SocialRelation::Audience),
|
||||
"mutual" => Ok(SocialRelation::Mutual),
|
||||
// Legacy DB values from v0.6.1 and earlier — map to Follow.
|
||||
"audience" | "mutual" => Ok(SocialRelation::Follow),
|
||||
_ => Err(anyhow::anyhow!("unknown social relation: {}", s)),
|
||||
}
|
||||
}
|
||||
|
|
@ -822,8 +783,9 @@ pub struct InlineComment {
|
|||
pub enum CommentPermission {
|
||||
/// Anyone can comment
|
||||
Public,
|
||||
/// Only people in author's audience can comment
|
||||
AudienceOnly,
|
||||
/// Only people the author follows publicly can comment.
|
||||
/// Renamed from `AudienceOnly` in v0.6.2 when audience was removed.
|
||||
FollowersOnly,
|
||||
/// Comments disabled
|
||||
None,
|
||||
}
|
||||
|
|
@ -858,8 +820,9 @@ impl Default for ReactPermission {
|
|||
pub enum ModerationMode {
|
||||
/// Author maintains a blocklist of users
|
||||
AuthorBlocklist,
|
||||
/// Only audience members can engage
|
||||
AudienceOnly,
|
||||
/// Only people the author follows publicly can engage.
|
||||
/// Renamed from `AudienceOnly` in v0.6.2.
|
||||
FollowersOnly,
|
||||
}
|
||||
|
||||
impl Default for ModerationMode {
|
||||
|
|
|
|||
|
|
@ -1356,111 +1356,6 @@ async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchor
|
|||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WormResultDto {
|
||||
|
|
@ -2401,7 +2296,7 @@ async fn set_comment_policy(
|
|||
let node = get_node(&state).await;
|
||||
let pid = hex_to_postid(&post_id)?;
|
||||
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,
|
||||
_ => itsgoin_core::types::CommentPermission::Public,
|
||||
};
|
||||
|
|
@ -3011,11 +2906,6 @@ pub fn run() {
|
|||
set_anchors,
|
||||
list_anchor_peers,
|
||||
list_known_anchors,
|
||||
list_audience,
|
||||
list_audience_outbound,
|
||||
request_audience,
|
||||
approve_audience,
|
||||
remove_audience,
|
||||
list_connections,
|
||||
worm_lookup,
|
||||
list_social_routes,
|
||||
|
|
|
|||
123
frontend/app.js
123
frontend/app.js
|
|
@ -1422,14 +1422,8 @@ async function loadPeerBios(container) {
|
|||
|
||||
async function loadFollows() {
|
||||
try {
|
||||
const [follows, outbound, inbound] = await Promise.all([
|
||||
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));
|
||||
// v0.6.2: audience removed. No more audience/mutual badges or request flow.
|
||||
const follows = await invoke('list_follows');
|
||||
|
||||
// Filter out self before rendering
|
||||
const others = follows.filter(f => f.nodeId !== myNodeId);
|
||||
|
|
@ -1443,34 +1437,21 @@ async function loadFollows() {
|
|||
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
||||
const isSelf = f.nodeId === myNodeId;
|
||||
|
||||
let audienceBadge = '';
|
||||
let mutualBadge = '';
|
||||
let lastSeenHtml = '';
|
||||
let actions = '';
|
||||
if (isSelf) {
|
||||
actions = '<span class="self-tag">(you)</span>';
|
||||
} 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) {
|
||||
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 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>`;
|
||||
actions = `${audienceBtn} ${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
||||
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
||||
}
|
||||
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>` : ''}
|
||||
<div class="peer-card-bio"></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
|
||||
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() {
|
||||
try {
|
||||
const records = await invoke('list_audience');
|
||||
const pending = records.filter(r => r.status === 'pending');
|
||||
const approved = records.filter(r => r.status === 'approved');
|
||||
|
||||
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>`;
|
||||
}
|
||||
if (audiencePendingList) audiencePendingList.style.display = 'none';
|
||||
if (audienceApprovedList) audienceApprovedList.style.display = 'none';
|
||||
const headings = document.querySelectorAll('.audience-section, #audience-section');
|
||||
headings.forEach(el => { el.style.display = 'none'; });
|
||||
}
|
||||
|
||||
// --- Network diagnostics ---
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@
|
|||
<select id="circle-select" class="hidden"></select>
|
||||
<select id="comment-perm-select" title="Comment permission">
|
||||
<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>
|
||||
</select>
|
||||
<select id="react-perm-select" title="React permission">
|
||||
|
|
@ -132,16 +132,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<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">
|
||||
<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="connect-toggle" class="btn btn-ghost btn-sm">Add peer manually</button>
|
||||
<div id="connect-body" class="hidden">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue