v0.4.2: Welcome screen, status ticker, notifications, text scaling, networking fixes
Welcome screen with staggered counters while backend bootstraps. Header status ticker for new posts/messages/reactions/comments/connection changes. Notification fallback chain (Tauri plugin → Web API → notify-rust). Responsive text scaling (Small/Normal/Large, persisted). Diagnostics moved to popover with on-demand connections. Share details lightbox with QR code. Connect string prefers external address. Stale N1 fix (disconnected routes excluded). Replication handler actively fetches posts+blobs from requester. Hole punch registers remote address for relay. Replication semaphore (3 concurrent). Peer labels show truncated node ID. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79922a9208
commit
6004cae8a8
10 changed files with 446 additions and 95 deletions
292
frontend/app.js
292
frontend/app.js
|
|
@ -51,7 +51,7 @@ let networkSummaryEl = null; // created dynamically inside diagnostics popover
|
|||
const resetDataBtn = $('#reset-data-btn');
|
||||
|
||||
// --- State ---
|
||||
let currentTab = 'feed';
|
||||
let currentTab = 'welcome';
|
||||
let connectString = '';
|
||||
let myNodeId = '';
|
||||
const POST_MAX_CHARS = 500;
|
||||
|
|
@ -361,20 +361,39 @@ let _activeNotificationIds = new Set();
|
|||
|
||||
async function maybeNotify(title, body, tag) {
|
||||
try {
|
||||
if (window.__TAURI__?.notification) {
|
||||
const { isPermissionGranted, requestPermission, sendNotification } = window.__TAURI__.notification;
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
const perm = await requestPermission();
|
||||
granted = perm === 'granted';
|
||||
}
|
||||
if (granted) {
|
||||
sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined });
|
||||
if (tag) _activeNotificationIds.add(tag);
|
||||
}
|
||||
} else if ('Notification' in window) {
|
||||
if (Notification.permission === 'default') await Notification.requestPermission();
|
||||
if (Notification.permission === 'granted') new Notification(title, { body, tag, silent: false });
|
||||
showTicker(title);
|
||||
// Try Tauri plugin first, then Web Notification API, then invoke fallback
|
||||
let sent = false;
|
||||
const tauriNotif = window.__TAURI__?.notification || window.__TAURI__?.plugin?.notification;
|
||||
if (tauriNotif) {
|
||||
try {
|
||||
const { isPermissionGranted, requestPermission, sendNotification } = tauriNotif;
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
const perm = await requestPermission();
|
||||
granted = perm === 'granted';
|
||||
}
|
||||
if (granted) {
|
||||
sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined });
|
||||
if (tag) _activeNotificationIds.add(tag);
|
||||
sent = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!sent && 'Notification' in window && Notification.permission !== 'denied') {
|
||||
try {
|
||||
if (Notification.permission === 'default') await Notification.requestPermission();
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, { body, tag, silent: false });
|
||||
sent = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!sent) {
|
||||
// Last resort: try invoke to Rust-side notification
|
||||
try {
|
||||
await invoke('send_notification', { title, body });
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -446,7 +465,7 @@ function relativeTime(timestampMs) {
|
|||
}
|
||||
|
||||
function peerLabel(nodeId, displayName) {
|
||||
if (displayName) return displayName;
|
||||
if (displayName) return `${displayName} (${nodeId.substring(0, 6)})`;
|
||||
return nodeId.substring(0, 12) + '...';
|
||||
}
|
||||
|
||||
|
|
@ -579,6 +598,20 @@ function updateTabBadge(tabName, count) {
|
|||
let _lastFeedViewMs = 0;
|
||||
let _lastMyPostsViewMs = 0;
|
||||
|
||||
let _tickerTimeout = null;
|
||||
let _lastTickerMs = 0;
|
||||
function showTicker(msg) {
|
||||
const now = Date.now();
|
||||
if (now - _lastTickerMs < 1000) return; // max 1 update/sec
|
||||
_lastTickerMs = now;
|
||||
const el = $('#status-ticker');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('faded');
|
||||
if (_tickerTimeout) clearTimeout(_tickerTimeout);
|
||||
_tickerTimeout = setTimeout(() => { el.classList.add('faded'); }, 3000);
|
||||
}
|
||||
|
||||
async function updateNetworkIndicator() {
|
||||
try {
|
||||
const info = await invoke('get_network_summary');
|
||||
|
|
@ -596,6 +629,14 @@ async function updateNetworkIndicator() {
|
|||
if (info.hasPublicV6) labelHtml += '<span class="net-label">Public</span>';
|
||||
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
|
||||
if (labels) labels.innerHTML = labelHtml;
|
||||
// Only show connection ticker on state change
|
||||
if (typeof updateNetworkIndicator._lastTotal === 'undefined') updateNetworkIndicator._lastTotal = -1;
|
||||
if (total !== updateNetworkIndicator._lastTotal) {
|
||||
if (total === 0) showTicker('Connecting...');
|
||||
else if (updateNetworkIndicator._lastTotal === 0) showTicker('Connected');
|
||||
else if (total > updateNetworkIndicator._lastTotal) showTicker(`${total} peers connected`);
|
||||
updateNetworkIndicator._lastTotal = total;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
|
@ -666,7 +707,18 @@ async function loadFeed(force) {
|
|||
const oldFp = _feedFingerprint;
|
||||
_feedFingerprint = fp;
|
||||
|
||||
// Notify on new posts and engagement (DB-backed seen tracking)
|
||||
// Ticker for new posts from others
|
||||
if (_notifReady && oldFp) {
|
||||
const oldIds = new Set(oldFp.split('|').map(s => s.split(':')[0]));
|
||||
for (const p of posts) {
|
||||
if (!p.isMe && !oldIds.has(p.id)) {
|
||||
const name = p.authorName || p.author.substring(0, 8);
|
||||
showTicker(`New post from ${name}`);
|
||||
break; // one ticker per cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
// Notify on engagement (DB-backed seen tracking)
|
||||
if (_notifReady && oldFp) {
|
||||
try {
|
||||
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
|
||||
|
|
@ -680,10 +732,12 @@ async function loadFeed(force) {
|
|||
if (totalReacts > seen.seenReactCount) {
|
||||
const newReacts = totalReacts - seen.seenReactCount;
|
||||
maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`);
|
||||
showTicker(`New reaction on your post`);
|
||||
}
|
||||
if (totalComments > seen.seenCommentCount) {
|
||||
const newComments = totalComments - seen.seenCommentCount;
|
||||
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
|
||||
showTicker(`New comment on your post`);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
|
@ -696,6 +750,8 @@ async function loadFeed(force) {
|
|||
if (postEl) expandedComments.add(postEl.dataset.postId);
|
||||
});
|
||||
if (posts.length === 0) {
|
||||
// Don't lock in empty fingerprint — let next refresh re-render when posts arrive
|
||||
_feedFingerprint = null;
|
||||
feedList.innerHTML = renderEmptyState(
|
||||
'Your feed is empty',
|
||||
'Follow peers on the People tab to see their posts here.'
|
||||
|
|
@ -839,6 +895,7 @@ async function loadMessages(force) {
|
|||
const name = thread.partnerName || partnerId.slice(0, 8);
|
||||
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
||||
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
||||
showTicker(`New message from ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1556,7 +1613,13 @@ async function loadConnections() {
|
|||
}
|
||||
|
||||
async function loadAllDiagnostics() {
|
||||
await Promise.all([loadNetworkSummary(), loadConnections(), loadPeers(), loadActivityLog()]);
|
||||
const tasks = [loadNetworkSummary(), loadActivityLog()];
|
||||
// Only load connections if section is visible
|
||||
const connSection = $('#connections-section');
|
||||
if (connSection && !connSection.classList.contains('hidden')) {
|
||||
tasks.push(loadConnections());
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
lastDiagUpdate = Date.now();
|
||||
const ts = $('#diag-update-time');
|
||||
if (ts) ts.textContent = 'Updated ' + relativeTime(lastDiagUpdate);
|
||||
|
|
@ -1567,13 +1630,7 @@ let activityInterval = null;
|
|||
async function loadActivityLog() {
|
||||
try {
|
||||
const data = await invoke('get_activity_log');
|
||||
// Render timers
|
||||
const timersEl = $('#activity-timers');
|
||||
if (timersEl) {
|
||||
const now = Date.now();
|
||||
timersEl.innerHTML = renderTimer('Rebalance', data.rebalanceLastMs, data.rebalanceIntervalSecs, now)
|
||||
+ renderTimer('Anchor Register', data.anchorRegisterLastMs, data.anchorRegisterIntervalSecs, now);
|
||||
}
|
||||
// Timers removed — rebalance/anchor register countdowns not useful for users
|
||||
// Render events (newest first)
|
||||
const logEl = $('#activity-log');
|
||||
if (logEl) {
|
||||
|
|
@ -2800,20 +2857,32 @@ $('#diagnostics-btn').addEventListener('click', () => {
|
|||
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
|
||||
<span id="diag-update-time" class="diag-timestamp"></span>
|
||||
</div>
|
||||
<h4 class="subsection-title">Timers</h4>
|
||||
<div id="activity-timers" class="diag-grid" style="grid-template-columns: 1fr 1fr;"></div>
|
||||
<button id="show-connections-btn" class="btn btn-ghost btn-sm" style="margin-top:0.5rem">Show Connections</button>
|
||||
<div id="connections-section" class="hidden">
|
||||
<h4 class="subsection-title">Mesh & Session Connections</h4>
|
||||
<div id="connections-list"></div>
|
||||
</div>
|
||||
<h4 class="subsection-title">Activity Log</h4>
|
||||
<div id="activity-log" class="activity-log-container"></div>
|
||||
<h4 class="subsection-title">Mesh Connections</h4>
|
||||
<div id="connections-list"></div>
|
||||
<h4 class="subsection-title">Known Peers</h4>
|
||||
<div id="peers-list"></div>`;
|
||||
<div id="activity-log" class="activity-log-container"></div>`;
|
||||
openPopover('Network Diagnostics', diagHtml, {
|
||||
onOpen() {
|
||||
// Re-bind dynamic element refs
|
||||
networkSummaryEl = $('#network-summary');
|
||||
connectionsList = $('#connections-list');
|
||||
peersList = $('#peers-list');
|
||||
peersList = null; // Known peers removed
|
||||
// Wire connections toggle
|
||||
$('#show-connections-btn').addEventListener('click', () => {
|
||||
const section = $('#connections-section');
|
||||
const btn = $('#show-connections-btn');
|
||||
if (section.classList.contains('hidden')) {
|
||||
section.classList.remove('hidden');
|
||||
btn.textContent = 'Hide Connections';
|
||||
loadConnections();
|
||||
} else {
|
||||
section.classList.add('hidden');
|
||||
btn.textContent = 'Show Connections';
|
||||
}
|
||||
});
|
||||
// Wire action buttons
|
||||
$('#diag-refresh-btn').addEventListener('click', async () => {
|
||||
const btn = $('#diag-refresh-btn');
|
||||
|
|
@ -2869,10 +2938,38 @@ connectInput.addEventListener('keydown', (e) => {
|
|||
$('#connect-toggle').addEventListener('click', () => {
|
||||
const body = $('#connect-body');
|
||||
body.classList.toggle('hidden');
|
||||
$('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually...' : 'Cancel';
|
||||
$('#connect-toggle').textContent = body.classList.contains('hidden') ? 'Add peer manually' : 'Cancel';
|
||||
});
|
||||
$('#share-details-btn').addEventListener('click', () => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-lightbox';
|
||||
overlay.style.cursor = 'default';
|
||||
let qrSvg = '';
|
||||
try { qrSvg = generateQRCodeSVG(connectString, 200); } catch (_) {}
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
|
||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Share My Details</h3>
|
||||
<div style="margin-bottom:0.75rem">${qrSvg}</div>
|
||||
<div class="connect-string-box" style="margin-bottom:0.75rem;font-size:0.7rem">${escapeHtml(connectString)}</div>
|
||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||
<button class="btn btn-primary btn-sm" id="share-copy-btn">Copy</button>
|
||||
<button class="btn btn-ghost btn-sm" id="share-close-btn">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('#share-copy-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectString);
|
||||
toast('Connect string copied!');
|
||||
} catch (_) {
|
||||
prompt('Copy your connect string:', connectString);
|
||||
}
|
||||
});
|
||||
overlay.querySelector('#share-close-btn').addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
});
|
||||
syncBtn.addEventListener('click', doSyncAll);
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(connectString);
|
||||
toast('Connect string copied!');
|
||||
|
|
@ -2881,7 +2978,7 @@ copyBtn.addEventListener('click', async () => {
|
|||
prompt('Copy your connect string:', connectString);
|
||||
}
|
||||
});
|
||||
exportKeyBtn.addEventListener('click', async () => {
|
||||
if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const key = await invoke('export_identity');
|
||||
try {
|
||||
|
|
@ -2909,6 +3006,28 @@ $('#circle-profiles-toggle').addEventListener('click', () => {
|
|||
});
|
||||
|
||||
// --- Notifications popover ---
|
||||
// Text size toggle
|
||||
const TEXT_SIZE_SCALES = { small: '100%', normal: '150%', large: '200%' };
|
||||
// Apply text size immediately (default Normal = 150%)
|
||||
document.documentElement.style.fontSize = '150%';
|
||||
(async () => {
|
||||
const saved = await invoke('get_setting', { key: 'text_size' }).catch(() => null) || 'normal';
|
||||
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[saved] || '150%';
|
||||
document.querySelectorAll('.text-size-opt').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.size === saved);
|
||||
});
|
||||
})();
|
||||
document.querySelectorAll('.text-size-opt').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const size = btn.dataset.size;
|
||||
document.documentElement.style.fontSize = TEXT_SIZE_SCALES[size] || '';
|
||||
document.querySelectorAll('.text-size-opt').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
await invoke('set_setting', { key: 'text_size', value: size }).catch(() => {});
|
||||
toast('Text size updated');
|
||||
});
|
||||
});
|
||||
|
||||
$('#notifications-btn').addEventListener('click', async () => {
|
||||
// Load current settings
|
||||
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
||||
|
|
@ -2968,41 +3087,84 @@ setupName.addEventListener('keydown', (e) => {
|
|||
|
||||
// --- Init ---
|
||||
async function init() {
|
||||
// Backend setup may still be running — retry until state is managed
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
await invoke('get_node_info');
|
||||
break; // backend ready
|
||||
} catch (e) {
|
||||
if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; }
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
}
|
||||
|
||||
updateCharCount();
|
||||
const info = await loadNodeInfo();
|
||||
await loadStats();
|
||||
await loadFeed();
|
||||
await loadMessages();
|
||||
// Now safe to fire notifications (initial data loaded, won't spam)
|
||||
_notifReady = true;
|
||||
|
||||
// Show setup overlay if no profile exists
|
||||
if (info && !info.hasProfile) {
|
||||
setupOverlay.classList.remove('hidden');
|
||||
setupName.focus();
|
||||
}
|
||||
|
||||
// Initialize feed view timestamp
|
||||
_lastFeedViewMs = Date.now();
|
||||
updateNetworkIndicator().catch(() => {});
|
||||
|
||||
// Initial network indicator
|
||||
updateNetworkIndicator();
|
||||
// Welcome screen — stagger count reveals every 2 seconds
|
||||
let _welcomeTick = 0;
|
||||
let _welcomeValues = [0, 0, 0, 0, 0];
|
||||
const welcomeFields = ['welcome-connections', 'welcome-posts', 'welcome-messages', 'welcome-reacts', 'welcome-comments'];
|
||||
|
||||
// Fetch data in background (non-blocking — updates _welcomeValues + tab badges + notifications)
|
||||
const welcomeFetch = () => {
|
||||
invoke('get_network_summary').then(info => {
|
||||
_welcomeValues[0] = info.totalConnections || 0;
|
||||
}).catch(() => {});
|
||||
invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }).then(b => {
|
||||
_welcomeValues[1] = b.newFeed || 0;
|
||||
_welcomeValues[2] = b.unreadMessages || 0;
|
||||
_welcomeValues[3] = b.newReacts || 0;
|
||||
_welcomeValues[4] = b.newComments || 0;
|
||||
// Update tab badges from welcome screen
|
||||
updateTabBadge('feed', b.newFeed || 0);
|
||||
updateTabBadge('myposts', b.newEngagement || 0);
|
||||
updateTabBadge('messages', b.unreadMessages || 0);
|
||||
// Ticker + notifications only after user leaves welcome screen
|
||||
// (welcome page already shows these counts directly)
|
||||
}).catch(() => {});
|
||||
};
|
||||
// Stagger reveals — one field every 2 seconds (first fetch happens on first tick)
|
||||
let _welcomeRevealed = 0;
|
||||
const welcomeInterval = setInterval(() => {
|
||||
if (currentTab !== 'welcome') {
|
||||
clearInterval(welcomeInterval);
|
||||
return;
|
||||
}
|
||||
// Reveal next field
|
||||
if (_welcomeRevealed < welcomeFields.length) {
|
||||
const el = document.getElementById(welcomeFields[_welcomeRevealed]);
|
||||
if (el) el.textContent = _welcomeValues[_welcomeRevealed];
|
||||
_welcomeRevealed++;
|
||||
}
|
||||
// Update all revealed fields with latest data
|
||||
welcomeFetch();
|
||||
for (let i = 0; i < _welcomeRevealed; i++) {
|
||||
const el = document.getElementById(welcomeFields[i]);
|
||||
if (el) el.textContent = _welcomeValues[i];
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Wait for backend in the background, then load node info
|
||||
(async () => {
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
await invoke('get_node_info');
|
||||
break;
|
||||
} catch (e) {
|
||||
if (attempt === 29) { console.error('Backend not ready after 30 attempts'); return; }
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
}
|
||||
const info = await loadNodeInfo();
|
||||
if (info && !info.hasProfile) {
|
||||
setupOverlay.classList.remove('hidden');
|
||||
setupName.focus();
|
||||
}
|
||||
// Reload feed now that backend is ready
|
||||
loadFeed(true).catch(() => {});
|
||||
loadMessages(true).catch(() => {});
|
||||
})();
|
||||
|
||||
// Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam)
|
||||
setTimeout(() => { _notifReady = true; }, 6000);
|
||||
|
||||
// Auto-refresh every 10 seconds — only the active tab
|
||||
const _initTime = Date.now();
|
||||
setInterval(() => {
|
||||
if (currentTab === 'feed') loadFeed();
|
||||
if (currentTab === 'myposts') loadMyPosts();
|
||||
const startup = Date.now() - _initTime < 30000; // force during first 30s
|
||||
if (currentTab === 'feed') loadFeed(startup);
|
||||
if (currentTab === 'myposts') loadMyPosts(startup);
|
||||
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
||||
updateNetworkIndicator();
|
||||
}, 10000);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue