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
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -2746,13 +2746,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"hex",
|
"hex",
|
||||||
"itsgoin-core",
|
"itsgoin-core",
|
||||||
|
"notify-rust",
|
||||||
"open",
|
"open",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -3818,13 +3818,16 @@ impl ConnectionManager {
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &requester_addrs, our_nat_profile, peer_nat_profile).await {
|
if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &requester_addrs, our_nat_profile, peer_nat_profile).await {
|
||||||
// Register as session so the connection is actually used
|
// Register as session with the peer's address for relay introduction
|
||||||
|
let remote_sock = requester_addrs.iter()
|
||||||
|
.filter_map(|a| a.parse::<std::net::SocketAddr>().ok())
|
||||||
|
.find(|s| crate::network::is_publicly_routable(s));
|
||||||
let mut cm = conn_mgr_arc.lock().await;
|
let mut cm = conn_mgr_arc.lock().await;
|
||||||
if cm.is_connected(&requester) {
|
if cm.is_connected(&requester) {
|
||||||
// Initiator already connected to us (their punch succeeded first)
|
// Initiator already connected to us (their punch succeeded first)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cm.add_session(requester, conn, SessionReachMethod::HolePunch, None);
|
cm.add_session(requester, conn, SessionReachMethod::HolePunch, remote_sock);
|
||||||
cm.mark_reachable(&requester);
|
cm.mark_reachable(&requester);
|
||||||
cm.log_activity(
|
cm.log_activity(
|
||||||
ActivityLevel::Info,
|
ActivityLevel::Info,
|
||||||
|
|
@ -5664,6 +5667,12 @@ impl ConnectionManager {
|
||||||
write_typed_message(&mut send, MessageType::BlobHeaderResponse, &response).await?;
|
write_typed_message(&mut send, MessageType::BlobHeaderResponse, &response).await?;
|
||||||
}
|
}
|
||||||
MessageType::ReplicationRequest => {
|
MessageType::ReplicationRequest => {
|
||||||
|
// Limit to 3 concurrent replication handlers to prevent overload
|
||||||
|
static REPLICATION_SEMAPHORE: std::sync::LazyLock<tokio::sync::Semaphore> =
|
||||||
|
std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(3));
|
||||||
|
let _permit = REPLICATION_SEMAPHORE.acquire().await
|
||||||
|
.map_err(|_| anyhow::anyhow!("replication semaphore closed"))?;
|
||||||
|
|
||||||
let payload: ReplicationRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
let payload: ReplicationRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
||||||
let (accepted, rejected, needs_pull) = {
|
let (accepted, rejected, needs_pull) = {
|
||||||
let cm = conn_mgr.lock().await;
|
let cm = conn_mgr.lock().await;
|
||||||
|
|
@ -5711,9 +5720,84 @@ impl ConnectionManager {
|
||||||
needs_pull = needs_pull_count,
|
needs_pull = needs_pull_count,
|
||||||
"Handled replication request"
|
"Handled replication request"
|
||||||
);
|
);
|
||||||
// Posts we accepted but don't have will be fetched on the next pull cycle
|
// Actively fetch posts we accepted but don't have from the requester
|
||||||
// from the requester (they have these posts since they asked us to hold them).
|
if !needs_pull.is_empty() {
|
||||||
// No explicit pull spawn needed — the periodic pull cycle handles it.
|
let cm_arc = conn_mgr.clone();
|
||||||
|
let sender = remote_node_id;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let conn = {
|
||||||
|
let cm = cm_arc.lock().await;
|
||||||
|
cm.connections_ref().get(&sender).map(|pc| pc.connection.clone())
|
||||||
|
.or_else(|| cm.sessions.get(&sender).map(|sc| sc.connection.clone()))
|
||||||
|
};
|
||||||
|
let Some(conn) = conn else { return };
|
||||||
|
let mut fetched = 0usize;
|
||||||
|
for post_id in &needs_pull {
|
||||||
|
// PostFetch without holding any lock
|
||||||
|
let result: anyhow::Result<Option<crate::protocol::SyncPost>> = async {
|
||||||
|
let (mut send, mut recv) = conn.open_bi().await?;
|
||||||
|
let req = crate::protocol::PostFetchRequestPayload { post_id: *post_id };
|
||||||
|
write_typed_message(&mut send, MessageType::PostFetchRequest, &req).await?;
|
||||||
|
send.finish()?;
|
||||||
|
let msg_type = read_message_type(&mut recv).await?;
|
||||||
|
if msg_type != MessageType::PostFetchResponse {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let resp: crate::protocol::PostFetchResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?;
|
||||||
|
Ok(resp.post)
|
||||||
|
}.await;
|
||||||
|
|
||||||
|
if let Ok(Some(sp)) = result {
|
||||||
|
if crate::content::verify_post_id(&sp.id, &sp.post) {
|
||||||
|
let attachments = sp.post.attachments.clone();
|
||||||
|
let post_author = sp.post.author;
|
||||||
|
let cm = cm_arc.lock().await;
|
||||||
|
let storage = cm.storage.lock().await;
|
||||||
|
let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility);
|
||||||
|
let prio = storage.get_post_upstreams(&sp.id).map(|v| v.len() as u8).unwrap_or(0);
|
||||||
|
let _ = storage.add_post_upstream(&sp.id, &sender, prio);
|
||||||
|
let blob_store = cm.blob_store.clone();
|
||||||
|
drop(storage);
|
||||||
|
drop(cm);
|
||||||
|
fetched += 1;
|
||||||
|
|
||||||
|
// Fetch blobs for this post from the requester
|
||||||
|
for att in &attachments {
|
||||||
|
if blob_store.has(&att.cid) { continue; }
|
||||||
|
let blob_result: anyhow::Result<()> = async {
|
||||||
|
let (mut bs, mut br) = conn.open_bi().await?;
|
||||||
|
let req = BlobRequestPayload {
|
||||||
|
cid: att.cid,
|
||||||
|
requester_addresses: vec![],
|
||||||
|
};
|
||||||
|
write_typed_message(&mut bs, MessageType::BlobRequest, &req).await?;
|
||||||
|
bs.finish()?;
|
||||||
|
let mt = read_message_type(&mut br).await?;
|
||||||
|
if mt != MessageType::BlobResponse { return Ok(()); }
|
||||||
|
let resp: BlobResponsePayload = read_payload(&mut br, MAX_PAYLOAD).await?;
|
||||||
|
if resp.found {
|
||||||
|
use base64::Engine;
|
||||||
|
let data = base64::engine::general_purpose::STANDARD.decode(resp.data_b64.as_bytes())?;
|
||||||
|
blob_store.store(&att.cid, &data)?;
|
||||||
|
let cm = cm_arc.lock().await;
|
||||||
|
let storage = cm.storage.lock().await;
|
||||||
|
let _ = storage.record_blob(&att.cid, post_id, &post_author, data.len() as u64, &att.mime_type, att.size_bytes);
|
||||||
|
let _ = storage.add_post_upstream(&att.cid, &sender, 0);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}.await;
|
||||||
|
if let Err(e) = blob_result {
|
||||||
|
debug!(cid = hex::encode(att.cid), error = %e, "Replication blob fetch failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fetched > 0 {
|
||||||
|
debug!(fetched, peer = hex::encode(sender), "Fetched replicated posts from requester");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
warn!(msg_type = ?other, "Unexpected message type on bi-stream");
|
warn!(msg_type = ?other, "Unexpected message type on bi-stream");
|
||||||
|
|
@ -7189,7 +7273,9 @@ impl ConnectionActor {
|
||||||
}
|
}
|
||||||
if let Ok(routes) = storage.list_social_routes() {
|
if let Ok(routes) = storage.list_social_routes() {
|
||||||
for route in &routes {
|
for route in &routes {
|
||||||
set.insert(route.node_id);
|
if route.status == crate::types::SocialStatus::Online {
|
||||||
|
set.insert(route.node_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for nid in &sticky_peers {
|
for nid in &sticky_peers {
|
||||||
|
|
|
||||||
|
|
@ -2883,10 +2883,12 @@ impl Storage {
|
||||||
for (nid, _, _) in mesh_peers {
|
for (nid, _, _) in mesh_peers {
|
||||||
ids.insert(nid);
|
ids.insert(nid);
|
||||||
}
|
}
|
||||||
// Add social routes
|
// Add only ONLINE social routes (not disconnected)
|
||||||
let routes = self.list_social_routes()?;
|
let routes = self.list_social_routes()?;
|
||||||
for route in routes {
|
for route in routes {
|
||||||
ids.insert(route.node_id);
|
if route.status == crate::types::SocialStatus::Online {
|
||||||
|
ids.insert(route.node_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(ids.into_iter().collect())
|
Ok(ids.into_iter().collect())
|
||||||
}
|
}
|
||||||
|
|
@ -4870,9 +4872,16 @@ mod tests {
|
||||||
preferred_tree: vec![],
|
preferred_tree: vec![],
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
|
// Disconnected routes should NOT be in N1 share
|
||||||
let n1 = s.build_n1_share().unwrap();
|
let n1 = s.build_n1_share().unwrap();
|
||||||
assert!(n1.contains(&peer_a));
|
assert!(n1.contains(&peer_a));
|
||||||
assert!(n1.contains(&follow_b));
|
assert!(!n1.contains(&follow_b), "Disconnected social route should not be in N1");
|
||||||
|
|
||||||
|
// Set to Online — now it should be included
|
||||||
|
s.set_social_route_status(&follow_b, SocialStatus::Online).unwrap();
|
||||||
|
let n1 = s.build_n1_share().unwrap();
|
||||||
|
assert!(n1.contains(&peer_a));
|
||||||
|
assert!(n1.contains(&follow_b), "Online social route should be in N1");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "itsgoin-desktop"
|
name = "itsgoin-desktop"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|
@ -24,3 +24,4 @@ base64 = "0.22"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
open = "5"
|
open = "5"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
notify-rust = "4"
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,9 @@ struct StatsDto {
|
||||||
struct BadgeCountsDto {
|
struct BadgeCountsDto {
|
||||||
new_feed: usize,
|
new_feed: usize,
|
||||||
new_engagement: usize,
|
new_engagement: usize,
|
||||||
|
unread_messages: usize,
|
||||||
|
new_reacts: usize,
|
||||||
|
new_comments: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -313,7 +316,18 @@ async fn get_node_info(state: State<'_, AppState>) -> Result<NodeInfoDto, String
|
||||||
let node = state.inner();
|
let node = state.inner();
|
||||||
let node_id_hex = hex::encode(node.node_id);
|
let node_id_hex = hex::encode(node.node_id);
|
||||||
let addr = node.endpoint_addr();
|
let addr = node.endpoint_addr();
|
||||||
let connect_string = if let Some(sock) = addr.ip_addrs().next() {
|
// Prefer external address (UPnP, public IPv6, observed) over local bind address
|
||||||
|
let external_addr = node.network.http_addr();
|
||||||
|
let observed_addr = if external_addr.is_none() {
|
||||||
|
let storage = node.storage.lock().await;
|
||||||
|
storage.get_peer_record(&node.node_id).ok().flatten()
|
||||||
|
.and_then(|r| r.addresses.first().map(|a| a.to_string()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let connect_string = if let Some(ext) = external_addr.or(observed_addr) {
|
||||||
|
format!("{}@{}", node_id_hex, ext)
|
||||||
|
} else if let Some(sock) = addr.ip_addrs().next() {
|
||||||
format!("{}@{}", node_id_hex, sock)
|
format!("{}@{}", node_id_hex, sock)
|
||||||
} else {
|
} else {
|
||||||
node_id_hex.clone()
|
node_id_hex.clone()
|
||||||
|
|
@ -1348,6 +1362,19 @@ struct CacheStatsDto {
|
||||||
blob_count: u64,
|
blob_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_notification(title: String, body: String) -> Result<(), String> {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let _ = notify_rust::Notification::new()
|
||||||
|
.summary(&title)
|
||||||
|
.body(&body)
|
||||||
|
.appname("ItsGoin")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_cache_stats(state: State<'_, AppState>) -> Result<CacheStatsDto, String> {
|
async fn get_cache_stats(state: State<'_, AppState>) -> Result<CacheStatsDto, String> {
|
||||||
let node = state.inner();
|
let node = state.inner();
|
||||||
|
|
@ -1456,7 +1483,47 @@ async fn get_badge_counts(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BadgeCountsDto { new_feed, new_engagement })
|
// Unread messages: count conversations with messages newer than last_read
|
||||||
|
let mut unread_messages = 0usize;
|
||||||
|
let dm_posts = all_posts.iter().filter(|(id, p, _)| {
|
||||||
|
matches!(
|
||||||
|
storage.get_post_intent(id).ok().flatten(),
|
||||||
|
Some(VisibilityIntent::Direct(_))
|
||||||
|
) || (p.author != node.node_id && matches!(
|
||||||
|
storage.get_post_with_visibility(id).ok().flatten(),
|
||||||
|
Some((_, PostVisibility::Encrypted { .. }))
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let mut seen_partners = std::collections::HashSet::new();
|
||||||
|
for (_id, post, _vis) in dm_posts {
|
||||||
|
let partner = if post.author == node.node_id {
|
||||||
|
// sent DM — skip for unread count
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
post.author
|
||||||
|
};
|
||||||
|
if seen_partners.contains(&partner) { continue; }
|
||||||
|
seen_partners.insert(partner);
|
||||||
|
let last_read = storage.get_last_read_message(&partner).unwrap_or(0);
|
||||||
|
if post.timestamp_ms > last_read {
|
||||||
|
unread_messages += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count new reacts and comments separately
|
||||||
|
let mut new_reacts = 0usize;
|
||||||
|
let mut new_comments = 0usize;
|
||||||
|
for (id, post, _vis) in &all_posts {
|
||||||
|
if post.author != node.node_id { continue; }
|
||||||
|
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
|
||||||
|
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
|
||||||
|
let total_comments = storage.get_comment_count(id).unwrap_or(0);
|
||||||
|
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
|
||||||
|
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
|
||||||
|
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -2112,6 +2179,7 @@ pub fn run() {
|
||||||
get_message_receipts,
|
get_message_receipts,
|
||||||
get_message_comments,
|
get_message_comments,
|
||||||
get_cache_stats,
|
get_cache_stats,
|
||||||
|
send_notification,
|
||||||
get_setting,
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
mark_post_seen,
|
mark_post_seen,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"productName": "itsgoin",
|
"productName": "itsgoin",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"identifier": "com.itsgoin.app",
|
"identifier": "com.itsgoin.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../frontend",
|
"frontendDist": "../../frontend",
|
||||||
|
|
|
||||||
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');
|
const resetDataBtn = $('#reset-data-btn');
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
let currentTab = 'feed';
|
let currentTab = 'welcome';
|
||||||
let connectString = '';
|
let connectString = '';
|
||||||
let myNodeId = '';
|
let myNodeId = '';
|
||||||
const POST_MAX_CHARS = 500;
|
const POST_MAX_CHARS = 500;
|
||||||
|
|
@ -361,20 +361,39 @@ let _activeNotificationIds = new Set();
|
||||||
|
|
||||||
async function maybeNotify(title, body, tag) {
|
async function maybeNotify(title, body, tag) {
|
||||||
try {
|
try {
|
||||||
if (window.__TAURI__?.notification) {
|
showTicker(title);
|
||||||
const { isPermissionGranted, requestPermission, sendNotification } = window.__TAURI__.notification;
|
// Try Tauri plugin first, then Web Notification API, then invoke fallback
|
||||||
let granted = await isPermissionGranted();
|
let sent = false;
|
||||||
if (!granted) {
|
const tauriNotif = window.__TAURI__?.notification || window.__TAURI__?.plugin?.notification;
|
||||||
const perm = await requestPermission();
|
if (tauriNotif) {
|
||||||
granted = perm === 'granted';
|
try {
|
||||||
}
|
const { isPermissionGranted, requestPermission, sendNotification } = tauriNotif;
|
||||||
if (granted) {
|
let granted = await isPermissionGranted();
|
||||||
sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined });
|
if (!granted) {
|
||||||
if (tag) _activeNotificationIds.add(tag);
|
const perm = await requestPermission();
|
||||||
}
|
granted = perm === 'granted';
|
||||||
} else if ('Notification' in window) {
|
}
|
||||||
if (Notification.permission === 'default') await Notification.requestPermission();
|
if (granted) {
|
||||||
if (Notification.permission === 'granted') new Notification(title, { body, tag, silent: false });
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +465,7 @@ function relativeTime(timestampMs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function peerLabel(nodeId, displayName) {
|
function peerLabel(nodeId, displayName) {
|
||||||
if (displayName) return displayName;
|
if (displayName) return `${displayName} (${nodeId.substring(0, 6)})`;
|
||||||
return nodeId.substring(0, 12) + '...';
|
return nodeId.substring(0, 12) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,6 +598,20 @@ function updateTabBadge(tabName, count) {
|
||||||
let _lastFeedViewMs = 0;
|
let _lastFeedViewMs = 0;
|
||||||
let _lastMyPostsViewMs = 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() {
|
async function updateNetworkIndicator() {
|
||||||
try {
|
try {
|
||||||
const info = await invoke('get_network_summary');
|
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.hasPublicV6) labelHtml += '<span class="net-label">Public</span>';
|
||||||
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
|
if (info.hasPublicV4 || info.hasUpnp) labelHtml += '<span class="net-label">Server</span>';
|
||||||
if (labels) labels.innerHTML = labelHtml;
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -666,7 +707,18 @@ async function loadFeed(force) {
|
||||||
const oldFp = _feedFingerprint;
|
const oldFp = _feedFingerprint;
|
||||||
_feedFingerprint = fp;
|
_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) {
|
if (_notifReady && oldFp) {
|
||||||
try {
|
try {
|
||||||
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
|
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
|
||||||
|
|
@ -680,10 +732,12 @@ async function loadFeed(force) {
|
||||||
if (totalReacts > seen.seenReactCount) {
|
if (totalReacts > seen.seenReactCount) {
|
||||||
const newReacts = totalReacts - seen.seenReactCount;
|
const newReacts = totalReacts - seen.seenReactCount;
|
||||||
maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`);
|
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) {
|
if (totalComments > seen.seenCommentCount) {
|
||||||
const newComments = totalComments - seen.seenCommentCount;
|
const newComments = totalComments - seen.seenCommentCount;
|
||||||
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
|
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
|
||||||
|
showTicker(`New comment on your post`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -696,6 +750,8 @@ async function loadFeed(force) {
|
||||||
if (postEl) expandedComments.add(postEl.dataset.postId);
|
if (postEl) expandedComments.add(postEl.dataset.postId);
|
||||||
});
|
});
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0) {
|
||||||
|
// Don't lock in empty fingerprint — let next refresh re-render when posts arrive
|
||||||
|
_feedFingerprint = null;
|
||||||
feedList.innerHTML = renderEmptyState(
|
feedList.innerHTML = renderEmptyState(
|
||||||
'Your feed is empty',
|
'Your feed is empty',
|
||||||
'Follow peers on the People tab to see their posts here.'
|
'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 name = thread.partnerName || partnerId.slice(0, 8);
|
||||||
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
|
||||||
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
|
||||||
|
showTicker(`New message from ${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1556,7 +1613,13 @@ async function loadConnections() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAllDiagnostics() {
|
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();
|
lastDiagUpdate = Date.now();
|
||||||
const ts = $('#diag-update-time');
|
const ts = $('#diag-update-time');
|
||||||
if (ts) ts.textContent = 'Updated ' + relativeTime(lastDiagUpdate);
|
if (ts) ts.textContent = 'Updated ' + relativeTime(lastDiagUpdate);
|
||||||
|
|
@ -1567,13 +1630,7 @@ let activityInterval = null;
|
||||||
async function loadActivityLog() {
|
async function loadActivityLog() {
|
||||||
try {
|
try {
|
||||||
const data = await invoke('get_activity_log');
|
const data = await invoke('get_activity_log');
|
||||||
// Render timers
|
// Timers removed — rebalance/anchor register countdowns not useful for users
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Render events (newest first)
|
// Render events (newest first)
|
||||||
const logEl = $('#activity-log');
|
const logEl = $('#activity-log');
|
||||||
if (logEl) {
|
if (logEl) {
|
||||||
|
|
@ -2800,20 +2857,32 @@ $('#diagnostics-btn').addEventListener('click', () => {
|
||||||
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
|
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
|
||||||
<span id="diag-update-time" class="diag-timestamp"></span>
|
<span id="diag-update-time" class="diag-timestamp"></span>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="subsection-title">Timers</h4>
|
<button id="show-connections-btn" class="btn btn-ghost btn-sm" style="margin-top:0.5rem">Show Connections</button>
|
||||||
<div id="activity-timers" class="diag-grid" style="grid-template-columns: 1fr 1fr;"></div>
|
<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>
|
<h4 class="subsection-title">Activity Log</h4>
|
||||||
<div id="activity-log" class="activity-log-container"></div>
|
<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>`;
|
|
||||||
openPopover('Network Diagnostics', diagHtml, {
|
openPopover('Network Diagnostics', diagHtml, {
|
||||||
onOpen() {
|
onOpen() {
|
||||||
// Re-bind dynamic element refs
|
// Re-bind dynamic element refs
|
||||||
networkSummaryEl = $('#network-summary');
|
networkSummaryEl = $('#network-summary');
|
||||||
connectionsList = $('#connections-list');
|
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
|
// Wire action buttons
|
||||||
$('#diag-refresh-btn').addEventListener('click', async () => {
|
$('#diag-refresh-btn').addEventListener('click', async () => {
|
||||||
const btn = $('#diag-refresh-btn');
|
const btn = $('#diag-refresh-btn');
|
||||||
|
|
@ -2869,10 +2938,38 @@ connectInput.addEventListener('keydown', (e) => {
|
||||||
$('#connect-toggle').addEventListener('click', () => {
|
$('#connect-toggle').addEventListener('click', () => {
|
||||||
const body = $('#connect-body');
|
const body = $('#connect-body');
|
||||||
body.classList.toggle('hidden');
|
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);
|
syncBtn.addEventListener('click', doSyncAll);
|
||||||
copyBtn.addEventListener('click', async () => {
|
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(connectString);
|
await navigator.clipboard.writeText(connectString);
|
||||||
toast('Connect string copied!');
|
toast('Connect string copied!');
|
||||||
|
|
@ -2881,7 +2978,7 @@ copyBtn.addEventListener('click', async () => {
|
||||||
prompt('Copy your connect string:', connectString);
|
prompt('Copy your connect string:', connectString);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
exportKeyBtn.addEventListener('click', async () => {
|
if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const key = await invoke('export_identity');
|
const key = await invoke('export_identity');
|
||||||
try {
|
try {
|
||||||
|
|
@ -2909,6 +3006,28 @@ $('#circle-profiles-toggle').addEventListener('click', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Notifications popover ---
|
// --- 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 () => {
|
$('#notifications-btn').addEventListener('click', async () => {
|
||||||
// Load current settings
|
// Load current settings
|
||||||
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
||||||
|
|
@ -2968,41 +3087,84 @@ setupName.addEventListener('keydown', (e) => {
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
async function 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();
|
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();
|
_lastFeedViewMs = Date.now();
|
||||||
|
updateNetworkIndicator().catch(() => {});
|
||||||
|
|
||||||
// Initial network indicator
|
// Welcome screen — stagger count reveals every 2 seconds
|
||||||
updateNetworkIndicator();
|
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
|
// Auto-refresh every 10 seconds — only the active tab
|
||||||
|
const _initTime = Date.now();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (currentTab === 'feed') loadFeed();
|
const startup = Date.now() - _initTime < 30000; // force during first 30s
|
||||||
if (currentTab === 'myposts') loadMyPosts();
|
if (currentTab === 'feed') loadFeed(startup);
|
||||||
|
if (currentTab === 'myposts') loadMyPosts(startup);
|
||||||
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
if (currentTab === 'people') { loadFollows(); loadPeers(); loadAudience(); }
|
||||||
updateNetworkIndicator();
|
updateNetworkIndicator();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
<header>
|
<header>
|
||||||
<div id="header-row">
|
<div id="header-row">
|
||||||
<h1>ItsGoin</h1>
|
<h1>ItsGoin</h1>
|
||||||
|
<div id="status-ticker"></div>
|
||||||
<div id="net-indicator">
|
<div id="net-indicator">
|
||||||
<span id="net-dot"></span>
|
<span id="net-dot"></span>
|
||||||
<span id="net-labels"></span>
|
<span id="net-labels"></span>
|
||||||
|
|
@ -29,15 +30,31 @@
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<nav id="tabs">
|
<nav id="tabs">
|
||||||
<button class="tab active" data-tab="feed">Feed</button>
|
<button class="tab" data-tab="feed">Feed</button>
|
||||||
<button class="tab" data-tab="myposts">My Posts</button>
|
<button class="tab" data-tab="myposts">My Posts</button>
|
||||||
<button class="tab" data-tab="people">People</button>
|
<button class="tab" data-tab="people">People</button>
|
||||||
<button class="tab" data-tab="messages">Messages</button>
|
<button class="tab" data-tab="messages">Messages</button>
|
||||||
<button class="tab" data-tab="settings">Settings</button>
|
<button class="tab" data-tab="settings">Settings</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Welcome (shown on startup) -->
|
||||||
|
<section id="view-welcome" class="view active">
|
||||||
|
<div style="text-align:center;padding:2rem 1rem">
|
||||||
|
<h2 style="color:#7fdbca;margin-bottom:0.25rem">Welcome back!</h2>
|
||||||
|
<p style="color:#e0e0e0;font-size:1.1rem;margin-bottom:0.5rem">How's it goin?</p>
|
||||||
|
<p style="color:#666;font-size:0.8rem;margin-bottom:1.5rem">Connecting and getting updates usually takes a couple minutes.<br>New things we've found so far:</p>
|
||||||
|
<div id="welcome-counts" style="display:flex;flex-wrap:wrap;gap:1rem;justify-content:center;color:#888;font-size:0.85rem">
|
||||||
|
<div><span id="welcome-connections" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Connections</div>
|
||||||
|
<div><span id="welcome-posts" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>New Posts</div>
|
||||||
|
<div><span id="welcome-messages" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Messages</div>
|
||||||
|
<div><span id="welcome-reacts" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Reacts</div>
|
||||||
|
<div><span id="welcome-comments" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Comments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Feed tab -->
|
<!-- Feed tab -->
|
||||||
<section id="view-feed" class="view active">
|
<section id="view-feed" class="view">
|
||||||
<div id="feed-list"></div>
|
<div id="feed-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -116,8 +133,9 @@
|
||||||
<div id="audience-approved-list"></div>
|
<div id="audience-approved-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card" style="display:flex;gap:0.5rem;flex-wrap:wrap">
|
||||||
<button id="connect-toggle" class="btn btn-ghost btn-sm">Add peer manually...</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>
|
||||||
<div id="connect-body" class="hidden">
|
<div id="connect-body" class="hidden">
|
||||||
<div class="input-row" style="margin-top:0.5rem">
|
<div class="input-row" style="margin-top:0.5rem">
|
||||||
<input id="connect-input" placeholder="paste connect string: nodeid@ip:port" />
|
<input id="connect-input" placeholder="paste connect string: nodeid@ip:port" />
|
||||||
|
|
@ -182,15 +200,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h3>Identity</h3>
|
<div id="node-info" style="display:none">
|
||||||
<div id="node-info">
|
<span id="node-id"></span>
|
||||||
<span id="node-id">loading...</span>
|
|
||||||
</div>
|
|
||||||
<div id="connect-string-display" class="connect-string-box"></div>
|
|
||||||
<div id="qr-code" class="qr-container"></div>
|
|
||||||
<div class="button-row">
|
|
||||||
<button id="copy-connect" class="btn btn-ghost" title="Copy connect string">Copy Connect String</button>
|
|
||||||
<button id="export-key-btn" class="btn btn-ghost" title="Export identity key">Export Key</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -215,6 +226,15 @@
|
||||||
<button id="diagnostics-btn" class="btn btn-ghost btn-full">Network Diagnostics</button>
|
<button id="diagnostics-btn" class="btn btn-ghost btn-full">Network Diagnostics</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<h3>Text Size</h3>
|
||||||
|
<div id="text-size-btns" style="display:flex;gap:0.4rem;margin-top:0.3rem">
|
||||||
|
<button class="notif-opt text-size-opt" data-size="small">Small</button>
|
||||||
|
<button class="notif-opt text-size-opt active" data-size="normal">Normal</button>
|
||||||
|
<button class="notif-opt text-size-opt" data-size="large">Large</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h3>Cache Storage</h3>
|
<h3>Cache Storage</h3>
|
||||||
<div id="cache-stats-display" class="empty-hint">Loading...</div>
|
<div id="cache-stats-display" class="empty-hint">Loading...</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: clamp(14px, 1.5vw, 24px); }
|
||||||
select option { color: #000 !important; }
|
select option { color: #000 !important; }
|
||||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 0 auto; padding: 1rem; background: #1a1a2e; color: #e0e0e0; color-scheme: dark; }
|
body { font-family: system-ui, sans-serif; max-width: clamp(640px, 80vw, 1600px); margin: 0 auto; padding: clamp(0.5rem, 2vw, 2rem); background: #1a1a2e; color: #e0e0e0; color-scheme: dark; }
|
||||||
header { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
header { border-bottom: 1px solid #333; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||||
#header-row { display: flex; justify-content: space-between; align-items: center; }
|
#header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
header h1 { font-size: 1.4rem; color: #7fdbca; margin: 0; }
|
header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
|
||||||
|
#status-ticker { flex: 1; text-align: center; font-size: 0.7rem; color: #888; line-height: 1.3; max-height: 2.4em; overflow: hidden; transition: opacity 0.3s; }
|
||||||
|
#status-ticker.faded { opacity: 0; }
|
||||||
#net-indicator { display: flex; align-items: center; gap: 0.4rem; }
|
#net-indicator { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
#net-dot { width: 10px; height: 10px; border-radius: 50%; background: #222; border: 1px solid #444; }
|
#net-dot { width: 10px; height: 10px; border-radius: 50%; background: #222; border: 1px solid #444; }
|
||||||
#net-dot.net-black { background: #222; }
|
#net-dot.net-black { background: #222; }
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@
|
||||||
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.</p>
|
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.</p>
|
||||||
<div class="card" style="margin-top: 1rem;">
|
<div class="card" style="margin-top: 1rem;">
|
||||||
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
|
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
|
||||||
<p style="margin-top: 0.5rem;"><strong>v0.4.1</strong> (2026-03-21): Security hardening — reaction signatures (ed25519), comment signature verification on receipt, reaction removal authorization, BlobHeader author verification. Lock contention fixes — ManifestPush discovery (cm lock released during I/O), pull request handler (filter without lock), pull sender (split into brief locks), engagement checker (batch writes per chunk). Data cleanup — post deletion cleans downstream/upstream/seen tables.</p>
|
<p style="margin-top: 0.5rem;"><strong>v0.4.2</strong> (2026-03-22): Welcome screen — startup shows “How’s it goin?” with staggered counters (connections, posts, messages, reacts, comments) while backend bootstraps. Status ticker — header ticker for new posts, messages, reactions, comments, connection changes. Notification improvements — Tauri plugin → Web Notification → notify-rust fallback chain, Linux native notifications. Responsive text scaling — Small/Normal/Large (100%/150%/200%), persisted via settings. Diagnostics popover — diagnostics moved from inline section to overlay, connections on-demand, timers removed. Share details lightbox with QR code. Connect string prefers external address (UPnP/public IPv6/observed). Stale N1 fix — disconnected social routes excluded from N1 share. Replication handler fix — actively fetches posts + blobs from requester after accepting replication. Hole punch fix — target-side registers publicly routable remote address for relay introduction. Replication semaphore (3 concurrent max). Peer labels show truncated node ID.</p>
|
||||||
|
<p><strong>v0.4.1</strong> (2026-03-21): Security hardening — reaction signatures (ed25519), comment signature verification on receipt, reaction removal authorization, BlobHeader author verification. Lock contention fixes — ManifestPush discovery (cm lock released during I/O), pull request handler (filter without lock), pull sender (split into brief locks), engagement checker (batch writes per chunk). Data cleanup — post deletion cleans downstream/upstream/seen tables.</p>
|
||||||
<p><strong>v0.4.0</strong> (2026-03-21): Protocol v4 — header-driven sync. ManifestPush as primary post notification. Slim PullSyncRequest (per-author timestamps, not full post ID list). Tiered engagement checks (5min/1hr/4hr/24hr by content age). Multi-upstream (3 max) with fallback chain. Auto-prefetch followed authors <90d. Self Last Encounter per-author tracking. Encrypted-but-not-for-us CDN caching. Serial engagement polling. ~90% bandwidth reduction for established nodes.</p>
|
<p><strong>v0.4.0</strong> (2026-03-21): Protocol v4 — header-driven sync. ManifestPush as primary post notification. Slim PullSyncRequest (per-author timestamps, not full post ID list). Tiered engagement checks (5min/1hr/4hr/24hr by content age). Multi-upstream (3 max) with fallback chain. Auto-prefetch followed authors <90d. Self Last Encounter per-author tracking. Encrypted-but-not-for-us CDN caching. Serial engagement polling. ~90% bandwidth reduction for established nodes.</p>
|
||||||
<p><strong>v0.3.6</strong> (2026-03-20): Active CDN replication — all devices proactively replicate recent posts to peers (desktops > anchors > phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset, phones 100MB/1GB, desktops 200MB/2GB, anchors 200MB/1GB. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix — BlobHeader JSON rebuilt after diff ops. Tombstone system — deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first. Network indicator — header dot (black/red/yellow/green) + capability labels. Tab badges — contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.</p>
|
<p><strong>v0.3.6</strong> (2026-03-20): Active CDN replication — all devices proactively replicate recent posts to peers (desktops > anchors > phones priority). ReplicationRequest/Response (0xE1/0xE2). Device roles (Intermittent/Available/Persistent) advertised in InitialExchange. Bandwidth budgets: replication (pull to cache) + delivery (serve requests), hourly auto-reset, phones 100MB/1GB, desktops 200MB/2GB, anchors 200MB/1GB. Cache management: 1GB default, configurable, eviction cycle activated with share-link priority boost. Engagement distribution fix — BlobHeader JSON rebuilt after diff ops. Tombstone system — deleted reactions/comments tombstoned, propagate via pull sync. Persistent notifications via seen_engagement/seen_messages tables. DOS hardening: fan-out cap (10), prefetch cap (20), downstream registration cap (50), delivery budget enforcement. Pull preference reordered: non-anchors first. Network indicator — header dot (black/red/yellow/green) + capability labels. Tab badges — contextual counts (new posts, engagement, online, unread). Message read tracking on open/close/send. Stats bar removed.</p>
|
||||||
<p><strong>v0.3.5</strong> (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt & comment slots — private posts carry noise-prefilled encrypted slots in BlobHeader for delivery/read/react receipts and private comments; CDN-propagated as opaque bytes; slot key derived from post CEK; 3 new BlobHeaderDiffOps (WriteReceiptSlot, WriteCommentSlot, AddCommentSlots). Message UI — DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.</p>
|
<p><strong>v0.3.5</strong> (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization. Encrypted receipt & comment slots — private posts carry noise-prefilled encrypted slots in BlobHeader for delivery/read/react receipts and private comments; CDN-propagated as opaque bytes; slot key derived from post CEK; 3 new BlobHeaderDiffOps (WriteReceiptSlot, WriteCommentSlot, AddCommentSlots). Message UI — DM delivery indicators (checkmark/double/blue/emoji), auto-seen on view, react button on messages.</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue