feat(fof-layer3): Mode 1 publish + read + Tauri + UI wiring
End-to-end FoFClosed (Mode 1: encrypted body + FoF comments): Node API: - create_post_fof_closed(content) -> (PostId, Post, cek) Builds gating, encrypts body via fof::encrypt_fof_body, base64s it into post.content, stores with visibility=FoFClosed + intent=Public, propagates via update_neighbor_manifests_as. - read_fof_closed_body(post_id) -> Option<String> Trial-unlocks via find_unlock_for_post, decrypts body, returns plaintext. Returns None for non-FoFClosed or non-member readers. Tauri commands: - create_post_fof_closed, read_fof_closed_body. Registered in generate_handler!. Feed rendering: - PostDto.visibility carries the new "fof-closed" string. - renderPost(): FoFClosed posts render with a locked placeholder (data-fof-closed-pending=post_id span). Visual badge added. - unlockFoFClosedPlaceholders(rootEl): post-render async pass that scans for placeholder spans and dispatches read_fof_closed_body for each. Fills in body for FoF readers; falls back to a "not in this FoF set" notice otherwise. - Wired into feed-list and my-posts-list render paths. Compose: - "Body+Comments: FoF only (Mode 1)" option in comment-perm-select. Selected → dispatches to create_post_fof_closed. CLI feed renderer + Tauri feed-DTO match arms updated to handle FoFClosed. New end-to-end test brings total to 146: - fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with Alice's V_me in his keyring) unlocks + decrypts; Carol (no matching V_x) cannot unlock and sees only ciphertext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
856f386231
commit
66b78041fc
6 changed files with 255 additions and 4 deletions
|
|
@ -905,6 +905,7 @@ async fn print_post(
|
||||||
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
||||||
format!(" [group-encrypted, epoch {}]", epoch)
|
format!(" [group-encrypted, epoch {}]", epoch)
|
||||||
}
|
}
|
||||||
|
itsgoin_core::types::PostVisibility::FoFClosed => " [fof-closed]".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ts = post.timestamp_ms / 1000;
|
let ts = post.timestamp_ms / 1000;
|
||||||
|
|
@ -917,6 +918,10 @@ async fn print_post(
|
||||||
Some(text) => text.to_string(),
|
Some(text) => text.to_string(),
|
||||||
None => "(encrypted)".to_string(),
|
None => "(encrypted)".to_string(),
|
||||||
},
|
},
|
||||||
|
itsgoin_core::types::PostVisibility::FoFClosed => match decrypted {
|
||||||
|
Some(text) => text.to_string(),
|
||||||
|
None => "(fof-closed; not in this FoF set)".to_string(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("---");
|
println!("---");
|
||||||
|
|
|
||||||
|
|
@ -1093,6 +1093,70 @@ mod tests {
|
||||||
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
|
assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End-to-end FoFClosed roundtrip at the helper level: Alice
|
||||||
|
/// encrypts a body; Bob (with Alice's V_me as a received V_x)
|
||||||
|
/// trial-unlocks the gating + decrypts the body. Carol (no
|
||||||
|
/// matching V_x) cannot unlock and the body stays opaque.
|
||||||
|
#[test]
|
||||||
|
fn fof_closed_body_end_to_end() {
|
||||||
|
use crate::types::PostingIdentity;
|
||||||
|
|
||||||
|
let s = temp_storage();
|
||||||
|
|
||||||
|
// Alice has V_me; she'll author a FoFClosed post.
|
||||||
|
let (alice_id, alice_seed) = make_persona(70);
|
||||||
|
s.upsert_posting_identity(&PostingIdentity {
|
||||||
|
node_id: alice_id, secret_seed: alice_seed,
|
||||||
|
display_name: "Alice".into(), created_at: 1000,
|
||||||
|
}).unwrap();
|
||||||
|
let mut v_me_alice = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut v_me_alice);
|
||||||
|
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
||||||
|
|
||||||
|
// Alice received Bob's V_x — so the gating includes Bob's slot.
|
||||||
|
let (bob_id, _bob_seed) = make_persona(71);
|
||||||
|
let mut v_x_bob = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut v_x_bob);
|
||||||
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
||||||
|
|
||||||
|
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
||||||
|
let body_plaintext = "secret to the FoF set only";
|
||||||
|
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
|
||||||
|
|
||||||
|
// Bob's device (with his V_me == v_x_bob) sees the gating block
|
||||||
|
// and trial-unlocks via his V_me.
|
||||||
|
let bob_storage = temp_storage();
|
||||||
|
bob_storage.upsert_posting_identity(&PostingIdentity {
|
||||||
|
node_id: bob_id, secret_seed: _bob_seed,
|
||||||
|
display_name: "Bob".into(), created_at: 1500,
|
||||||
|
}).unwrap();
|
||||||
|
bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap();
|
||||||
|
|
||||||
|
let alice_post = crate::types::Post {
|
||||||
|
author: alice_id, content: String::new(), attachments: vec![],
|
||||||
|
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
|
||||||
|
};
|
||||||
|
let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap()
|
||||||
|
.expect("Bob can unlock");
|
||||||
|
let bob_decrypted = decrypt_fof_body(&body_ct, &bob_unlock.cek, &built.slot_binder_nonce).unwrap();
|
||||||
|
assert_eq!(bob_decrypted, body_plaintext);
|
||||||
|
|
||||||
|
// Carol has no matching V_x — cannot unlock.
|
||||||
|
let carol_storage = temp_storage();
|
||||||
|
let (carol_id, carol_seed) = make_persona(72);
|
||||||
|
carol_storage.upsert_posting_identity(&PostingIdentity {
|
||||||
|
node_id: carol_id, secret_seed: carol_seed,
|
||||||
|
display_name: "Carol".into(), created_at: 1500,
|
||||||
|
}).unwrap();
|
||||||
|
let mut v_me_carol = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut v_me_carol);
|
||||||
|
carol_storage.insert_own_vouch_key(&carol_id, 1, &v_me_carol, 1500).unwrap();
|
||||||
|
|
||||||
|
let carol_unlock = find_unlock_for_post(&carol_storage, &alice_post).unwrap();
|
||||||
|
assert!(carol_unlock.is_none(),
|
||||||
|
"Carol has no matching V_x and cannot unlock the FoFClosed gating");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fof_body_padding_hides_real_length() {
|
fn fof_body_padding_hides_real_length() {
|
||||||
let cek = [0x55; 32];
|
let cek = [0x55; 32];
|
||||||
|
|
|
||||||
|
|
@ -1076,6 +1076,104 @@ impl Node {
|
||||||
Ok((post_id, post, visibility, cek))
|
Ok((post_id, post, visibility, cek))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FoF Layer 3: read the decrypted body of a FoFClosed post if any
|
||||||
|
/// of this device's personas can unlock it. Returns `Ok(None)` for
|
||||||
|
/// non-FoFClosed posts and for FoFClosed posts not reachable via
|
||||||
|
/// any held V_x. Errors only on storage/crypto faults.
|
||||||
|
pub async fn read_fof_closed_body(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
use base64::Engine;
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
let (post, visibility) = match storage.get_post_with_visibility(post_id)? {
|
||||||
|
Some(pv) => pv,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
if !matches!(visibility, PostVisibility::FoFClosed) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let gating = match post.fof_gating.as_ref() {
|
||||||
|
Some(g) => g,
|
||||||
|
None => return Ok(None), // invariant violation; treat as opaque
|
||||||
|
};
|
||||||
|
let slot_binder_nonce = gating.slot_binder_nonce;
|
||||||
|
|
||||||
|
let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(None), // we're not in the FoF set
|
||||||
|
};
|
||||||
|
drop(storage);
|
||||||
|
|
||||||
|
// Decode the base64-wrapped ciphertext + decrypt.
|
||||||
|
let body_ct = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(post.content.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?;
|
||||||
|
let plaintext = crate::fof::decrypt_fof_body(&body_ct, &unlock.cek, &slot_binder_nonce)?;
|
||||||
|
Ok(Some(plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FoF Layer 3: create a Mode 1 post (FoFClosed). The body is
|
||||||
|
/// encrypted under the gating CEK before storage; only readers
|
||||||
|
/// who can unlock a wrap_slot can decrypt it. Comments are also
|
||||||
|
/// FoF-gated, inheriting Layer 2's path.
|
||||||
|
///
|
||||||
|
/// Returns `(post_id, post, visibility, cek)`.
|
||||||
|
pub async fn create_post_fof_closed(
|
||||||
|
&self,
|
||||||
|
content: String,
|
||||||
|
) -> anyhow::Result<(PostId, Post, [u8; 32])> {
|
||||||
|
let built = {
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"default persona has no V_me; rotate or recreate before FoF posts"
|
||||||
|
))?
|
||||||
|
};
|
||||||
|
let cek = built.cek;
|
||||||
|
let slot_binder_nonce = built.slot_binder_nonce;
|
||||||
|
|
||||||
|
// Encrypt + pad body under the gating CEK. Output is base64'd
|
||||||
|
// so it can live in Post.content (which is a String).
|
||||||
|
let encrypted_body = crate::fof::encrypt_fof_body(&content, &cek, &slot_binder_nonce)?;
|
||||||
|
let body_b64 = {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&encrypted_body)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build + store + propagate. Visibility is FoFClosed (tag);
|
||||||
|
// gating lives in Post.fof_gating.
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
|
.as_millis() as u64;
|
||||||
|
let post = Post {
|
||||||
|
author: self.default_posting_id,
|
||||||
|
content: body_b64,
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp_ms: now,
|
||||||
|
fof_gating: Some(built.gating),
|
||||||
|
};
|
||||||
|
let post_id = crate::content::compute_post_id(&post);
|
||||||
|
|
||||||
|
{
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
storage.store_post_with_intent(
|
||||||
|
&post_id, &post,
|
||||||
|
&PostVisibility::FoFClosed,
|
||||||
|
&VisibilityIntent::Public,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_neighbor_manifests_as(
|
||||||
|
&self.default_posting_id,
|
||||||
|
&self.default_posting_secret,
|
||||||
|
&post_id,
|
||||||
|
now,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok((post_id, post, cek))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_post_inner(
|
async fn create_post_inner(
|
||||||
&self,
|
&self,
|
||||||
posting_id: &NodeId,
|
posting_id: &NodeId,
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,10 @@ async fn post_to_dto(
|
||||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
|
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
|
||||||
None => ("encrypted".to_string(), None),
|
None => ("encrypted".to_string(), None),
|
||||||
},
|
},
|
||||||
|
// FoF Layer 3: FoFClosed body. Decrypted is None from the sync
|
||||||
|
// feed-pre-decrypt helper; the frontend calls read_fof_closed_body
|
||||||
|
// for any post with visibility == "fof-closed" to fill in the body.
|
||||||
|
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
|
||||||
};
|
};
|
||||||
let recipients = match vis {
|
let recipients = match vis {
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
|
|
@ -346,6 +350,10 @@ async fn decrypt_just_created(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// FoF Layer 3: FoFClosed body decrypt happens via the dedicated
|
||||||
|
// async read_fof_closed_body command. This sync helper returns
|
||||||
|
// None and the frontend dispatches the FoF read explicitly.
|
||||||
|
PostVisibility::FoFClosed => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -910,6 +918,7 @@ async fn post_to_dto_batch(
|
||||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
|
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
|
||||||
None => ("encrypted".to_string(), None),
|
None => ("encrypted".to_string(), None),
|
||||||
},
|
},
|
||||||
|
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
|
||||||
};
|
};
|
||||||
let recipients = match vis {
|
let recipients = match vis {
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
|
|
@ -1192,6 +1201,33 @@ async fn revoke_fof_commenter(
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments.
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_post_fof_closed(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
content: String,
|
||||||
|
) -> Result<FoFPostCreatedDto, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let (post_id, _post, _cek) = node
|
||||||
|
.create_post_fof_closed(content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the decrypted body of a FoFClosed post if any local persona
|
||||||
|
/// can unlock it. `None` means "ciphertext only" (not in the FoF set).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn read_fof_closed_body(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
post_id_hex: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let pid = parse_node_id(&post_id_hex)?;
|
||||||
|
node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
|
@ -3213,6 +3249,8 @@ pub fn run() {
|
||||||
create_post_with_fof_comments,
|
create_post_with_fof_comments,
|
||||||
comment_on_fof_post,
|
comment_on_fof_post,
|
||||||
revoke_fof_commenter,
|
revoke_fof_commenter,
|
||||||
|
create_post_fof_closed,
|
||||||
|
read_fof_closed_body,
|
||||||
list_circles,
|
list_circles,
|
||||||
create_circle,
|
create_circle,
|
||||||
delete_circle,
|
delete_circle,
|
||||||
|
|
|
||||||
|
|
@ -486,11 +486,18 @@ function renderPost(post, index) {
|
||||||
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
||||||
} else if (post.visibility === 'encrypted') {
|
} else if (post.visibility === 'encrypted') {
|
||||||
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
visBadge = '<span class="vis-badge vis-encrypted">encrypted</span>';
|
||||||
|
} else if (post.visibility === 'fof-closed') {
|
||||||
|
visBadge = '<span class="vis-badge vis-encrypted-mine">fof-closed</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayContent;
|
let displayContent;
|
||||||
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
||||||
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
displayContent = '<span class="encrypted-placeholder">(encrypted)</span>';
|
||||||
|
} else if (post.visibility === 'fof-closed' && !post.decryptedContent) {
|
||||||
|
// FoF Layer 3: render as locked placeholder. The frontend fires
|
||||||
|
// an async read_fof_closed_body call after first paint to fill
|
||||||
|
// in the body for FoF readers (see loadFeed below).
|
||||||
|
displayContent = `<span class="encrypted-placeholder" data-fof-closed-pending="${post.id}">(fof-closed — unlocking…)</span>`;
|
||||||
} else if (post.decryptedContent) {
|
} else if (post.decryptedContent) {
|
||||||
displayContent = escapeHtml(post.decryptedContent);
|
displayContent = escapeHtml(post.decryptedContent);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -579,6 +586,29 @@ function renderMessage(post, index, showFollowBtn) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FoF Layer 3: post-render pass — find all "(fof-closed — unlocking…)"
|
||||||
|
/// placeholders in the given root and dispatch read_fof_closed_body.
|
||||||
|
/// Replaces each placeholder's text with the decrypted body, or with a
|
||||||
|
/// "not in this FoF set" notice if the caller can't unlock.
|
||||||
|
async function unlockFoFClosedPlaceholders(rootEl) {
|
||||||
|
const placeholders = rootEl.querySelectorAll('[data-fof-closed-pending]');
|
||||||
|
for (const el of placeholders) {
|
||||||
|
const postId = el.dataset.fofClosedPending;
|
||||||
|
try {
|
||||||
|
const body = await invoke('read_fof_closed_body', { postIdHex: postId });
|
||||||
|
if (body) {
|
||||||
|
el.textContent = body;
|
||||||
|
el.classList.remove('encrypted-placeholder');
|
||||||
|
} else {
|
||||||
|
el.textContent = '(fof-closed — not in this FoF set)';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
el.textContent = '(fof-closed — error)';
|
||||||
|
}
|
||||||
|
delete el.dataset.fofClosedPending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderEmptyState(message, hint) {
|
function renderEmptyState(message, hint) {
|
||||||
return `<div class="empty-state">
|
return `<div class="empty-state">
|
||||||
<div class="empty-state-icon"></div>
|
<div class="empty-state-icon"></div>
|
||||||
|
|
@ -793,6 +823,9 @@ async function loadFeed(force) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
|
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
|
||||||
|
// FoF Layer 3: any rendered FoFClosed post enters with a
|
||||||
|
// placeholder body; trigger the async unlock pass to fill in.
|
||||||
|
unlockFoFClosedPlaceholders(feedList);
|
||||||
if (authorFilterNodeId) {
|
if (authorFilterNodeId) {
|
||||||
const clearBtn = document.getElementById('clear-author-filter');
|
const clearBtn = document.getElementById('clear-author-filter');
|
||||||
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
||||||
|
|
@ -947,6 +980,7 @@ async function loadMyPosts(force) {
|
||||||
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
|
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
|
||||||
} else {
|
} else {
|
||||||
myPostsList.innerHTML = mine.map(renderPost).join('');
|
myPostsList.innerHTML = mine.map(renderPost).join('');
|
||||||
|
unlockFoFClosedPlaceholders(myPostsList);
|
||||||
if (_myPostsHasMore) {
|
if (_myPostsHasMore) {
|
||||||
const sentinel = document.createElement('div');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'myposts-scroll-sentinel';
|
sentinel.id = 'myposts-scroll-sentinel';
|
||||||
|
|
@ -2639,11 +2673,8 @@ async function doPost() {
|
||||||
// keyring. Routed through a dedicated command because the
|
// keyring. Routed through a dedicated command because the
|
||||||
// gating block is signed at publish time (can't be added
|
// gating block is signed at publish time (can't be added
|
||||||
// via SetPolicy after the fact).
|
// via SetPolicy after the fact).
|
||||||
// Attachments + non-default-persona FoF posts are not yet
|
|
||||||
// supported by the v1 command — fall through to the
|
|
||||||
// standard path with a warning if either applies.
|
|
||||||
if (selectedFiles.length > 0 || params.postingIdHex) {
|
if (selectedFiles.length > 0 || params.postingIdHex) {
|
||||||
toast('FoF posts with attachments or non-default persona require Mode 1 (later).');
|
toast('FoF posts with attachments or non-default persona not yet supported.');
|
||||||
postBtn.disabled = false;
|
postBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2651,6 +2682,20 @@ async function doPost() {
|
||||||
content: params.content,
|
content: params.content,
|
||||||
});
|
});
|
||||||
result = { id: created.postId };
|
result = { id: created.postId };
|
||||||
|
} else if (commentPerm === 'fof_closed') {
|
||||||
|
// FoF Layer 3 / Mode 1: body itself encrypted under the
|
||||||
|
// gating CEK. Non-FoF observers see only ciphertext;
|
||||||
|
// FoF readers unlock + decrypt on render via
|
||||||
|
// read_fof_closed_body.
|
||||||
|
if (selectedFiles.length > 0 || params.postingIdHex) {
|
||||||
|
toast('FoFClosed posts with attachments or non-default persona not yet supported.');
|
||||||
|
postBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await invoke('create_post_fof_closed', {
|
||||||
|
content: params.content,
|
||||||
|
});
|
||||||
|
result = { id: created.postId };
|
||||||
} else if (selectedFiles.length > 0) {
|
} else if (selectedFiles.length > 0) {
|
||||||
// Convert ArrayBuffers to base64 strings
|
// Convert ArrayBuffers to base64 strings
|
||||||
const files = selectedFiles.map(f => {
|
const files = selectedFiles.map(f => {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@
|
||||||
<option value="public">Comments: All</option>
|
<option value="public">Comments: All</option>
|
||||||
<option value="followers_only">Comments: Followers</option>
|
<option value="followers_only">Comments: Followers</option>
|
||||||
<option value="friends_of_friends">Comments: Friends of Friends</option>
|
<option value="friends_of_friends">Comments: Friends of Friends</option>
|
||||||
|
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue