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 <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) {
|
||||||
|
|
|
||||||
|
|
@ -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(¬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.
|
/// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) = {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
123
frontend/app.js
123
frontend/app.js
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue