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
|
|
@ -486,11 +486,18 @@ function renderPost(post, index) {
|
|||
visBadge = '<span class="vis-badge vis-encrypted-mine">encrypted</span>';
|
||||
} else if (post.visibility === 'encrypted') {
|
||||
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;
|
||||
if (post.visibility === 'encrypted' && !post.decryptedContent) {
|
||||
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) {
|
||||
displayContent = escapeHtml(post.decryptedContent);
|
||||
} else {
|
||||
|
|
@ -579,6 +586,29 @@ function renderMessage(post, index, showFollowBtn) {
|
|||
</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) {
|
||||
return `<div class="empty-state">
|
||||
<div class="empty-state-icon"></div>
|
||||
|
|
@ -793,6 +823,9 @@ async function loadFeed(force) {
|
|||
}
|
||||
} else {
|
||||
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) {
|
||||
const clearBtn = document.getElementById('clear-author-filter');
|
||||
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
||||
|
|
@ -947,6 +980,7 @@ async function loadMyPosts(force) {
|
|||
myPostsList.innerHTML = renderEmptyState('No posts yet', 'Write your first post above!');
|
||||
} else {
|
||||
myPostsList.innerHTML = mine.map(renderPost).join('');
|
||||
unlockFoFClosedPlaceholders(myPostsList);
|
||||
if (_myPostsHasMore) {
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'myposts-scroll-sentinel';
|
||||
|
|
@ -2639,11 +2673,8 @@ async function doPost() {
|
|||
// keyring. Routed through a dedicated command because the
|
||||
// gating block is signed at publish time (can't be added
|
||||
// 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) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
|
@ -2651,6 +2682,20 @@ async function doPost() {
|
|||
content: params.content,
|
||||
});
|
||||
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) {
|
||||
// Convert ArrayBuffers to base64 strings
|
||||
const files = selectedFiles.map(f => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
<option value="public">Comments: All</option>
|
||||
<option value="followers_only">Comments: Followers</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>
|
||||
</select>
|
||||
<select id="react-perm-select" title="React permission">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue