People tab rewrite: recency sort, profile-post Discover, bio modal,
per-author feed filter, ignore primitive The old People tab was built on network-layer presence (`is_online`, `last_seen` from the mesh), which was lost when v0.6.1 anonymized the network id from the posting id. Every named follow is authored under a posting id that doesn't appear in the connection-layer tables; the "Online" section listed nobody useful and Discover depended on the same broken signal. Replaced with signals derived from signed content: - Following is sorted by most-recent-post timestamp (the real meaning of "activity" in a post-anonymization world). - Discover lists named peers we've received signed profile posts from (via Phase 2d), filtered by follows / ignores / self. - Click-a-name surfaces a bio modal with View Posts / Follow / Message / Ignore actions. - Author-scoped feed filter (`View Posts` on any person) renders a "Showing posts from X" banner with a Clear button. - Ignore is a new local-only primitive; ignored peers' posts and profiles are excluded everywhere and the ignored list is editable in Settings. Core changes: - New `ignored_peers(node_id, ignored_at)` table + storage helpers (`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`, `is_ignored_peer`). Schema created fresh; no migration since the table is purely additive and empty on prior installs. - All 6 feed-query sites now also exclude `author IN ignored_peers`. - New `Storage::last_activity_for_authors(&[NodeId])` — one batched query returning max post timestamp per author, excluding non-feed intents (Control / Profile / Announcement / GroupKeyDistribute). - New `Storage::list_discoverable_profiles(&self_id)` — named profile rows where node_id is not self, not in follows, not in ignored, and `public_visible = 1`. Sorted by profile `updated_at` DESC. - New `Storage::delete_setting(key)` — missing counterpart to set/get. - Node wrappers: `last_activity_for_follows`, `ignore_peer` (also drops any follow + social route for the ignored peer), `unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`. - `list_follows` Tauri command now sources `last_activity_ms` from the posts-driven batched query rather than the network peer record. Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`, `list_ignored_peers`. Frontend: - Following list: see-new-activity button pattern (staged data + explicit user click to rearrange, so the list doesn't reorder under a tap mid-scroll). Periodic people-tab polling stages + lights up the button; clicking it re-renders. - Discover: rewrites the old peer-table-based list to a profile-post feed. Each card shows name + bio + profile-update age, plus Follow / Posts / Ignore actions. - Bio modal: reuses the existing generic popover. Loads display name + bio via `resolve_display`, shows follow state, offers View Posts / Follow-or-Unfollow / Message / Ignore-or-Unignore. - Author filter: banner renders at the top of the feed when active; clear button restores full feed. Filter state is a single `authorFilterNodeId` field consumed by `filterFeedPosts`. - Settings → Ignored section lists ignored peers with unignore buttons. 124 / 124 core tests pass.
This commit is contained in:
parent
e74bd4e6c6
commit
2ce668aa58
5 changed files with 595 additions and 168 deletions
498
frontend/app.js
498
frontend/app.js
|
|
@ -727,7 +727,12 @@ let _feedScrollObserver = null; // IntersectionObserver for infinite scroll
|
|||
let _feedPostIds = new Set(); // track loaded post IDs to avoid duplicates
|
||||
|
||||
function filterFeedPosts(posts) {
|
||||
return posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
|
||||
let filtered = posts.filter(p => p.intentKind !== 'direct' && !(p.intentKind === 'unknown' && (p.visibility === 'encrypted-for-me' || (p.isMe && p.recipients && p.recipients.length > 0))));
|
||||
// Per-author filter (driven by "View Posts" button or bio modal).
|
||||
if (authorFilterNodeId) {
|
||||
filtered = filtered.filter(p => p.author === authorFilterNodeId);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function loadFeed(force) {
|
||||
|
|
@ -769,11 +774,29 @@ async function loadFeed(force) {
|
|||
_feedHasMore = result.hasMore;
|
||||
_feedPostIds = new Set(posts.map(p => p.id));
|
||||
|
||||
const filterBanner = authorFilterNodeId
|
||||
? `<div id="author-filter-banner" style="padding:0.5rem 0.75rem;background:#1a1a2e;border-left:3px solid #7fdbca;margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;gap:0.5rem">
|
||||
<span>Showing posts from <strong>${escapeHtml(authorFilterName || authorFilterNodeId.slice(0, 12))}</strong></span>
|
||||
<button id="clear-author-filter" class="btn btn-ghost btn-sm">Clear</button>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
if (posts.length === 0) {
|
||||
_feedFingerprint = null;
|
||||
feedList.innerHTML = renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
|
||||
const empty = authorFilterNodeId
|
||||
? renderEmptyState('No posts from this person', 'They may not have published any visible posts yet.')
|
||||
: renderEmptyState('Your feed is empty', 'Follow peers on the People tab to see their posts here.');
|
||||
feedList.innerHTML = filterBanner + empty;
|
||||
if (authorFilterNodeId) {
|
||||
const clearBtn = document.getElementById('clear-author-filter');
|
||||
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
||||
}
|
||||
} else {
|
||||
feedList.innerHTML = posts.map(renderPost).join('');
|
||||
feedList.innerHTML = filterBanner + posts.map(renderPost).join('');
|
||||
if (authorFilterNodeId) {
|
||||
const clearBtn = document.getElementById('clear-author-filter');
|
||||
if (clearBtn) clearBtn.onclick = clearAuthorFilter;
|
||||
}
|
||||
// Add scroll sentinel at midpoint
|
||||
if (_feedHasMore) {
|
||||
const sentinel = document.createElement('div');
|
||||
|
|
@ -1420,186 +1443,282 @@ async function loadPeerBios(container) {
|
|||
}
|
||||
}
|
||||
|
||||
// Highest lastActivityMs shown at last render. When new activity arrives
|
||||
// that beats this, we show a "See new activity" button instead of
|
||||
// resorting under the user.
|
||||
let _followsDisplayedMaxActivity = 0;
|
||||
let _followsLatestMaxActivity = 0;
|
||||
let _followsPendingData = null; // cached follows array waiting for user's click
|
||||
|
||||
async function loadFollows() {
|
||||
try {
|
||||
// v0.6.2: audience removed. No more audience/mutual badges or request flow.
|
||||
// v0.6.2 People-tab rewrite:
|
||||
// - Online indicator is gone (network/posting id split hid presence).
|
||||
// - Sort by last post timestamp (DESC). Authors we've never seen
|
||||
// post go to the bottom.
|
||||
// - Don't resort under the user mid-interaction: when new activity
|
||||
// arrives, show a "See new activity" button instead.
|
||||
const follows = await invoke('list_follows');
|
||||
|
||||
// Filter out self before rendering
|
||||
const others = follows.filter(f => f.nodeId !== myNodeId);
|
||||
if (others.length === 0) {
|
||||
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Follow suggested peers or connect manually.')}</div>`;
|
||||
} else {
|
||||
const now = Date.now();
|
||||
const ONLINE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
||||
const renderFollowCard = (f) => {
|
||||
const icon = generateIdenticon(f.nodeId, 18);
|
||||
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
||||
const isSelf = f.nodeId === myNodeId;
|
||||
const newMax = others.reduce((m, f) => Math.max(m, f.lastActivityMs || 0), 0);
|
||||
|
||||
let lastSeenHtml = '';
|
||||
let actions = '';
|
||||
if (isSelf) {
|
||||
actions = '<span class="self-tag">(you)</span>';
|
||||
} else {
|
||||
if (!f.isOnline && f.lastActivityMs > 0) {
|
||||
lastSeenHtml = `<span class="last-seen">Last online: ${formatTimeAgo(f.lastActivityMs)}</span>`;
|
||||
}
|
||||
const syncBtn = `<button class="btn btn-ghost btn-sm sync-peer-btn" data-node-id="${f.nodeId}" title="Sync posts from this peer">Sync</button>`;
|
||||
const msgBtn = `<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${f.nodeId}" title="Send message">msg</button>`;
|
||||
const unfollowBtn = `<button class="btn btn-ghost btn-sm unfollow-btn" data-node-id="${f.nodeId}">Unfollow</button>`;
|
||||
actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`;
|
||||
}
|
||||
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
||||
<div class="peer-card-row">${icon} <a class="peer-name-link" data-node-id="${f.nodeId}">${label}</a></div>
|
||||
${lastSeenHtml ? `<div class="peer-card-lastseen">${lastSeenHtml}</div>` : ''}
|
||||
<div class="peer-card-bio"></div>
|
||||
<div class="peer-card-actions">${actions}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// If isOnline field isn't available (old build), show all as online
|
||||
const hasOnlineField = others.some(f => f.isOnline !== undefined);
|
||||
const online = hasOnlineField
|
||||
? others.filter(f => f.isOnline || (f.lastActivityMs > 0 && (now - f.lastActivityMs) < ONLINE_THRESHOLD))
|
||||
: others;
|
||||
const offline = hasOnlineField
|
||||
? others.filter(f => !online.includes(f))
|
||||
: [];
|
||||
|
||||
updateTabBadge('people', online.length);
|
||||
|
||||
let html = '';
|
||||
if (online.length > 0) {
|
||||
html += `<div class="follows-section-header">Following: Online (${online.length})</div>`;
|
||||
html += online.map(renderFollowCard).join('');
|
||||
}
|
||||
if (offline.length > 0) {
|
||||
html += `<div class="follows-section-header follows-offline-header" style="cursor:pointer">Following: Offline (${offline.length})</div>`;
|
||||
}
|
||||
followsList.innerHTML = html;
|
||||
|
||||
// Open offline follows in lightbox
|
||||
if (offline.length > 0) {
|
||||
followsList.querySelectorAll('.follows-offline-header').forEach(hdr => {
|
||||
hdr.addEventListener('click', () => {
|
||||
const existing = document.querySelector('.offline-lightbox');
|
||||
if (existing) existing.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'offline-lightbox';
|
||||
overlay.innerHTML = `
|
||||
<div class="offline-lightbox-content">
|
||||
<div class="offline-lightbox-header">
|
||||
<h3>Following: Offline (${offline.length})</h3>
|
||||
<button class="offline-lightbox-close">x</button>
|
||||
</div>
|
||||
<div class="offline-lightbox-list">${offline.map(renderFollowCard).join('')}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('.offline-lightbox-close').onclick = () => overlay.remove();
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
// Wire up buttons inside the lightbox
|
||||
attachFollowHandlers(overlay);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Attach unfollow handlers
|
||||
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Unfollowed');
|
||||
loadFollows();
|
||||
|
||||
loadStats();
|
||||
loadFeed(true);
|
||||
} catch (e) {
|
||||
toast('Error: ' + e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attach per-peer sync handlers
|
||||
followsList.querySelectorAll('.sync-peer-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Syncing...';
|
||||
try {
|
||||
await invoke('sync_from_peer', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Sync complete!');
|
||||
loadFeed(true);
|
||||
loadMyPosts(true);
|
||||
} catch (e) {
|
||||
toast('Error: ' + e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sync';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Lazy-load bios
|
||||
loadPeerBios(followsList);
|
||||
if (_followsDisplayedMaxActivity === 0) {
|
||||
_followsDisplayedMaxActivity = newMax;
|
||||
}
|
||||
_followsLatestMaxActivity = newMax;
|
||||
|
||||
// If the user has already rendered a list and new activity has come
|
||||
// in since, stash the new data and reveal the refresh button.
|
||||
if (newMax > _followsDisplayedMaxActivity && followsList.childElementCount > 0) {
|
||||
_followsPendingData = others;
|
||||
const btn = document.getElementById('follows-refresh-btn');
|
||||
if (btn) {
|
||||
const delta = others.filter(f => (f.lastActivityMs || 0) > _followsDisplayedMaxActivity).length;
|
||||
btn.textContent = `See ${delta} new update${delta === 1 ? '' : 's'}`;
|
||||
btn.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
renderFollowsList(others);
|
||||
_followsDisplayedMaxActivity = newMax;
|
||||
const btn = document.getElementById('follows-refresh-btn');
|
||||
if (btn) btn.classList.add('hidden');
|
||||
} catch (e) {
|
||||
followsList.innerHTML = `<div class="status-err">Error: ${e}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFollowsList(others) {
|
||||
if (others.length === 0) {
|
||||
followsList.innerHTML = `<div>${renderEmptyState('Not following anyone', 'Discover named people below or connect manually.')}</div>`;
|
||||
updateTabBadge('people', 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by last activity DESC; never-posted authors go to the bottom.
|
||||
const sorted = [...others].sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||
updateTabBadge('people', 0);
|
||||
|
||||
const renderFollowCard = (f) => {
|
||||
const icon = generateIdenticon(f.nodeId, 18);
|
||||
const label = escapeHtml(peerLabel(f.nodeId, f.displayName));
|
||||
const activity = (f.lastActivityMs || 0) > 0
|
||||
? `<span class="last-seen">Last posted ${formatTimeAgo(f.lastActivityMs)}</span>`
|
||||
: `<span class="last-seen">No posts yet</span>`;
|
||||
const viewBtn = `<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${f.nodeId}" data-name="${label}">Posts</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>`;
|
||||
return `<div class="peer-card" data-node-id="${f.nodeId}">
|
||||
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${f.nodeId}" data-name="${label}">${label}</a></div>
|
||||
<div class="peer-card-lastseen">${activity}</div>
|
||||
<div class="peer-card-bio"></div>
|
||||
<div class="peer-card-actions">${viewBtn} ${msgBtn} ${unfollowBtn}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
followsList.innerHTML = sorted.map(renderFollowCard).join('');
|
||||
|
||||
followsList.querySelectorAll('.unfollow-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke('unfollow_node', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Unfollowed');
|
||||
loadFollows();
|
||||
loadStats();
|
||||
loadFeed(true);
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
followsList.querySelectorAll('.view-posts-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
openAuthorFeed(btn.dataset.nodeId, btn.dataset.name);
|
||||
});
|
||||
});
|
||||
followsList.querySelectorAll('.bio-link').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||
});
|
||||
});
|
||||
|
||||
loadPeerBios(followsList);
|
||||
}
|
||||
|
||||
// loadSuggested removed — Suggested Peers section removed from UI
|
||||
|
||||
async function loadDiscoverPeople() {
|
||||
const container = $('#discover-list');
|
||||
try {
|
||||
const [peers, follows] = await Promise.all([
|
||||
invoke('list_peers'),
|
||||
invoke('list_follows'),
|
||||
]);
|
||||
const followSet = new Set(follows.map(f => f.nodeId));
|
||||
|
||||
// Filter: has display name, not already followed, not self
|
||||
const discoverable = peers.filter(p =>
|
||||
p.displayName && !followSet.has(p.nodeId) && p.nodeId !== myNodeId
|
||||
);
|
||||
|
||||
if (discoverable.length === 0) {
|
||||
// v0.6.2: Discover is driven by signed profile posts we've received
|
||||
// via the CDN. No liveness / network-presence dependency — if we
|
||||
// hold a profile post with a non-empty display_name from someone
|
||||
// we don't follow and haven't ignored, they show up.
|
||||
const profiles = await invoke('list_discover');
|
||||
if (!profiles || profiles.length === 0) {
|
||||
container.innerHTML = renderEmptyState(
|
||||
'No new people found',
|
||||
'Connect to more peers to discover people on the network.'
|
||||
'No new named profiles yet',
|
||||
'New people appear here as their signed profile posts propagate through the network.'
|
||||
);
|
||||
} else {
|
||||
container.innerHTML = discoverable.map(p => {
|
||||
const icon = generateIdenticon(p.nodeId, 18);
|
||||
const label = escapeHtml(p.displayName);
|
||||
let reachBadge = '';
|
||||
if (p.reach === 'mesh') reachBadge = '<span class="reach-badge reach-mesh">Mesh</span>';
|
||||
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
|
||||
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
|
||||
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
|
||||
|
||||
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
||||
<div class="peer-card-row">${icon} ${label} ${reachBadge}</div>
|
||||
<div class="peer-card-bio"></div>
|
||||
<div class="peer-card-actions">
|
||||
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
||||
<button class="btn btn-ghost btn-sm msg-peer-btn" data-node-id="${p.nodeId}" title="Send message">msg</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
attachFollowHandlers(container);
|
||||
loadPeerBios(container);
|
||||
return;
|
||||
}
|
||||
container.innerHTML = profiles.map(p => {
|
||||
const icon = generateIdenticon(p.nodeId, 18);
|
||||
const label = escapeHtml(p.displayName);
|
||||
const bioLine = p.bio
|
||||
? `<div class="peer-card-bio" style="font-size:0.8rem;color:#aaa;margin-top:0.15rem">${escapeHtml(p.bio)}</div>`
|
||||
: '';
|
||||
const ageLine = p.updatedAtMs
|
||||
? `<span class="last-seen">Profile updated ${formatTimeAgo(p.updatedAtMs)}</span>`
|
||||
: '';
|
||||
return `<div class="peer-card" data-node-id="${p.nodeId}">
|
||||
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${p.nodeId}" data-name="${label}">${label}</a></div>
|
||||
${bioLine}
|
||||
${ageLine ? `<div class="peer-card-lastseen">${ageLine}</div>` : ''}
|
||||
<div class="peer-card-actions">
|
||||
<button class="btn btn-primary btn-sm follow-btn" data-node-id="${p.nodeId}">Follow</button>
|
||||
<button class="btn btn-ghost btn-sm view-posts-btn" data-node-id="${p.nodeId}" data-name="${label}">Posts</button>
|
||||
<button class="btn btn-ghost btn-sm ignore-btn" data-node-id="${p.nodeId}" data-name="${label}">Ignore</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
attachFollowHandlers(container);
|
||||
container.querySelectorAll('.view-posts-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => openAuthorFeed(btn.dataset.nodeId, btn.dataset.name));
|
||||
});
|
||||
container.querySelectorAll('.bio-link').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.ignore-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Ignore ${btn.dataset.name || 'this peer'}? Their posts and profile will be hidden.`)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke('ignore_peer', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Ignored');
|
||||
loadDiscoverPeople();
|
||||
loadFeed(true);
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Bio modal + per-author feed filter ----
|
||||
|
||||
let authorFilterNodeId = null; // when set, feed shows only this author's posts
|
||||
let authorFilterName = null;
|
||||
|
||||
async function openBioModal(nodeId, preloadedName) {
|
||||
const overlay = document.getElementById('popover-overlay');
|
||||
const titleEl = document.getElementById('popover-title');
|
||||
const bodyEl = document.getElementById('popover-body');
|
||||
if (!overlay || !titleEl || !bodyEl) return;
|
||||
|
||||
titleEl.textContent = preloadedName || (nodeId ? nodeId.slice(0, 12) : 'Profile');
|
||||
bodyEl.innerHTML = '<p class="empty-hint">Loading…</p>';
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
|
||||
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
||||
const follows = await invoke('list_follows').catch(() => []);
|
||||
const ignored = await invoke('list_ignored_peers').catch(() => []);
|
||||
const following = follows.some(f => f.nodeId === nodeId);
|
||||
const isIgnored = ignored.some(i => i.nodeId === nodeId);
|
||||
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
|
||||
const bio = (resolved && resolved.bio) || '';
|
||||
const icon = generateIdenticon(nodeId, 48);
|
||||
|
||||
titleEl.textContent = name;
|
||||
bodyEl.innerHTML = `
|
||||
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
|
||||
${icon}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
|
||||
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
|
||||
</div>
|
||||
</div>
|
||||
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
||||
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
|
||||
${following
|
||||
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
|
||||
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
|
||||
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
|
||||
${isIgnored
|
||||
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
|
||||
: `<button id="bio-ignore" class="btn btn-danger btn-sm">Ignore</button>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => overlay.classList.add('hidden');
|
||||
|
||||
const vp = document.getElementById('bio-view-posts');
|
||||
if (vp) vp.onclick = () => { close(); openAuthorFeed(nodeId, name); };
|
||||
|
||||
const follow = document.getElementById('bio-follow');
|
||||
if (follow) follow.onclick = async () => {
|
||||
try { await invoke('follow_node', { nodeIdHex: nodeId }); toast('Followed'); close(); loadFollows(); loadFeed(true); }
|
||||
catch (e) { toast('Error: ' + e); }
|
||||
};
|
||||
const unfollow = document.getElementById('bio-unfollow');
|
||||
if (unfollow) unfollow.onclick = async () => {
|
||||
try { await invoke('unfollow_node', { nodeIdHex: nodeId }); toast('Unfollowed'); close(); loadFollows(); loadFeed(true); }
|
||||
catch (e) { toast('Error: ' + e); }
|
||||
};
|
||||
const msg = document.getElementById('bio-message');
|
||||
if (msg) msg.onclick = () => {
|
||||
close();
|
||||
// Switch to Messages tab + preselect this recipient if possible.
|
||||
const tab = document.querySelector('.tab[data-tab="messages"]');
|
||||
if (tab) tab.click();
|
||||
const sel = document.getElementById('dm-recipient-select');
|
||||
if (sel) {
|
||||
for (const opt of sel.options) {
|
||||
if (opt.value === nodeId) { sel.value = nodeId; break; }
|
||||
}
|
||||
}
|
||||
};
|
||||
const ignore = document.getElementById('bio-ignore');
|
||||
if (ignore) ignore.onclick = async () => {
|
||||
if (!confirm(`Ignore ${name}? They'll be hidden from feeds and Discover.`)) return;
|
||||
try { await invoke('ignore_peer', { nodeIdHex: nodeId }); toast('Ignored'); close(); loadFollows(); loadFeed(true); }
|
||||
catch (e) { toast('Error: ' + e); }
|
||||
};
|
||||
const unignore = document.getElementById('bio-unignore');
|
||||
if (unignore) unignore.onclick = async () => {
|
||||
try { await invoke('unignore_peer', { nodeIdHex: nodeId }); toast('Unignored'); close(); loadFeed(true); }
|
||||
catch (e) { toast('Error: ' + e); }
|
||||
};
|
||||
} catch (e) {
|
||||
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openAuthorFeed(nodeId, name) {
|
||||
authorFilterNodeId = nodeId;
|
||||
authorFilterName = name || nodeId.slice(0, 12);
|
||||
const feedTab = document.querySelector('.tab[data-tab="feed"]');
|
||||
if (feedTab) feedTab.click();
|
||||
loadFeed(true);
|
||||
}
|
||||
|
||||
function clearAuthorFilter() {
|
||||
authorFilterNodeId = null;
|
||||
authorFilterName = null;
|
||||
loadFeed(true);
|
||||
}
|
||||
|
||||
// Shared handler for follow/unfollow buttons in a container
|
||||
function attachFollowHandlers(container) {
|
||||
container.querySelectorAll('.follow-btn').forEach(btn => {
|
||||
|
|
@ -2932,14 +3051,18 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|||
}
|
||||
if (target === 'people') {
|
||||
if (!followsList.children.length) followsList.innerHTML = renderLoading();
|
||||
loadFollows(); loadAudience();
|
||||
loadFollows();
|
||||
loadAudience();
|
||||
// Refresh Discover if its section is already expanded.
|
||||
const disc = document.getElementById('discover-body');
|
||||
if (disc && !disc.classList.contains('hidden')) loadDiscoverPeople();
|
||||
}
|
||||
if (target === 'messages') {
|
||||
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
||||
loadMessages(true); loadDmRecipientOptions();
|
||||
clearNotifications('msg-');
|
||||
}
|
||||
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); }
|
||||
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); loadIgnored(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3046,6 +3169,22 @@ $('#discover-toggle').addEventListener('click', () => {
|
|||
if (!body.classList.contains('hidden')) loadDiscoverPeople();
|
||||
});
|
||||
|
||||
// "See new activity" button in Following: applies staged data and
|
||||
// re-renders so the user picks when to rearrange the list.
|
||||
{
|
||||
const refreshBtn = document.getElementById('follows-refresh-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
if (_followsPendingData) {
|
||||
renderFollowsList(_followsPendingData);
|
||||
_followsDisplayedMaxActivity = _followsLatestMaxActivity;
|
||||
_followsPendingData = null;
|
||||
}
|
||||
refreshBtn.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$('#anchors-toggle').addEventListener('click', () => {
|
||||
const body = $('#anchors-body');
|
||||
body.classList.toggle('hidden');
|
||||
|
|
@ -3442,6 +3581,43 @@ let personasCache = [];
|
|||
// 'all' (show everything) or a posting-id hex (filter to that persona).
|
||||
let feedPersonaFilter = 'all';
|
||||
|
||||
// --- Ignored peers list (Settings) ---
|
||||
async function loadIgnored() {
|
||||
const container = document.getElementById('ignored-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const ignored = await invoke('list_ignored_peers');
|
||||
if (!ignored || ignored.length === 0) {
|
||||
container.innerHTML = '<p class="empty-hint" style="margin:0">No ignored peers.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = ignored.map(i => {
|
||||
const label = escapeHtml(i.displayName || i.nodeId.slice(0, 12));
|
||||
const icon = generateIdenticon(i.nodeId, 18);
|
||||
return `<div class="peer-card" data-node-id="${i.nodeId}">
|
||||
<div class="peer-card-row">${icon} ${label}</div>
|
||||
<div class="peer-card-actions">
|
||||
<button class="btn btn-ghost btn-sm unignore-btn" data-node-id="${i.nodeId}">Unignore</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.querySelectorAll('.unignore-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke('unignore_peer', { nodeIdHex: btn.dataset.nodeId });
|
||||
toast('Unignored');
|
||||
loadIgnored();
|
||||
loadFeed(true);
|
||||
} catch (e) { toast('Error: ' + e); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Release announcement / upgrade banner ---
|
||||
async function loadUpgradeBanner() {
|
||||
const banner = document.getElementById('upgrade-banner');
|
||||
|
|
|
|||
|
|
@ -127,14 +127,18 @@
|
|||
<!-- People tab -->
|
||||
<section id="view-people" class="view">
|
||||
<div class="section-card">
|
||||
<h3>Following</h3>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;margin-bottom:0.4rem">
|
||||
<h3 style="margin:0">Following</h3>
|
||||
<button id="follows-refresh-btn" class="btn btn-ghost btn-sm hidden" style="background:#1f3f38;color:#7fdbca;border:1px solid #7fdbca">See new activity</button>
|
||||
</div>
|
||||
<p class="empty-hint" style="margin:0 0 0.5rem;font-size:0.75rem">Sorted by last post. Tap a name to see their bio.</p>
|
||||
<div id="follows-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
|
||||
<div id="discover-body" class="hidden">
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">People on the network with profiles you can follow.</p>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
|
||||
<div id="discover-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,6 +192,12 @@
|
|||
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.25rem">Ignored</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Peers whose posts and profiles are hidden from Feed, Messages, and Discover. Local-only; nothing on the wire says you've ignored them.</p>
|
||||
<div id="ignored-list" style="text-align:left"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="text-align:center">
|
||||
<h3 style="margin-bottom:0.25rem">Updates</h3>
|
||||
<p class="empty-hint" style="margin-bottom:0.5rem">Network-wide release announcements are signed by the bootstrap anchor and arrive via the CDN. Choose which channel to follow.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue