diff --git a/crates/tauri-app/capabilities/default.json b/crates/tauri-app/capabilities/default.json index 6fffe30..72d9e8c 100644 --- a/crates/tauri-app/capabilities/default.json +++ b/crates/tauri-app/capabilities/default.json @@ -9,6 +9,8 @@ "notification:allow-request-permission", "notification:allow-notify", "notification:allow-check-permissions", - "notification:allow-show" + "notification:allow-show", + "notification:allow-cancel", + "notification:allow-remove-active" ] } diff --git a/crates/tauri-app/gen/schemas/capabilities.json b/crates/tauri-app/gen/schemas/capabilities.json index 062c208..602c500 100644 --- a/crates/tauri-app/gen/schemas/capabilities.json +++ b/crates/tauri-app/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-check-permissions","notification:allow-show"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default","notification:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-check-permissions","notification:allow-show","notification:allow-cancel","notification:allow-remove-active"]}} \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js index a8f1193..29f08ac 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -357,6 +357,8 @@ function toast(msg) { // --- Notifications (Tauri plugin) --- let _notifReady = false; +let _activeNotificationIds = new Set(); + async function maybeNotify(title, body, tag) { try { if (window.__TAURI__?.notification) { @@ -367,16 +369,42 @@ async function maybeNotify(title, body, tag) { granted = perm === 'granted'; } if (granted) { - sendNotification({ title, body, channelId: 'default' }); + sendNotification({ title, body, channelId: 'default', id: tag ? hashCode(tag) : undefined }); + if (tag) _activeNotificationIds.add(tag); } } else if ('Notification' in window) { - // Fallback for browsers if (Notification.permission === 'default') await Notification.requestPermission(); if (Notification.permission === 'granted') new Notification(title, { body, tag, silent: false }); } } catch (_) {} } +async function clearNotifications(tagPrefix) { + try { + if (window.__TAURI__?.notification) { + const { removeActive } = window.__TAURI__.notification; + if (!removeActive) return; + const toRemove = [..._activeNotificationIds].filter(t => t.startsWith(tagPrefix)); + for (const tag of toRemove) { + try { + await removeActive({ notifications: [{ id: hashCode(tag) }] }); + } catch (_) {} + _activeNotificationIds.delete(tag); + } + } + } catch (_) {} +} + +function hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = ((hash << 5) - hash) + ch; + hash |= 0; + } + return Math.abs(hash); +} + // --- Popover helpers --- let popoverOnClose = null; function openPopover(title, html, opts = {}) { @@ -897,8 +925,9 @@ async function loadMessages(force) { const input = $('#popover-reply-input'); if (input) setTimeout(() => input.focus(), 100); - // Mark conversation as read (DB-backed) + // Mark conversation as read (DB-backed) and clear notifications invoke('mark_conversation_read', { partnerId }).catch(() => {}); + clearNotifications(`msg-`); // Mark incoming encrypted messages as "seen" for (const p of threadPosts) { @@ -952,6 +981,7 @@ async function loadMessages(force) { onClose() { // Mark conversation as read when closing the popover invoke('mark_conversation_read', { partnerId }).catch(() => {}); + clearNotifications(`msg-`); } }); }); @@ -2730,6 +2760,7 @@ document.querySelectorAll('.tab').forEach(tab => { if (target === 'messages') { if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading(); loadMessages(true); loadDmRecipientOptions(); + clearNotifications('msg-'); } if (target === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); } }); diff --git a/website/design.html b/website/design.html index 46a5447..e2cb41b 100644 --- a/website/design.html +++ b/website/design.html @@ -653,9 +653,9 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }

12. LAN Discovery

-

Status: Planned

+

Status: Complete

-

iroh's mDNS address lookup broadcasts peer presence on the local network via multicast DNS (service name "irohv1", backed by the swarm-discovery crate). Currently this is configured as a passive address resolver — if we already know a peer's NodeId, mDNS can resolve its LAN address. But mDNS also discovers unknown peers on the same network, and iroh exposes this via MdnsAddressLookup::subscribe().

+

mDNS-based LAN discovery is integrated via iroh's built-in MdnsAddressLookupBuilder. It works automatically — peers on the same local network are discovered and connected without manual configuration. iroh's mDNS address lookup broadcasts peer presence on the local network via multicast DNS (service name "irohv1", backed by the swarm-discovery crate).

Discovery flow

    @@ -885,17 +885,18 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }Blob content immutability

    Blob data is BLAKE3-addressed — the CID is the hash of the content. This means blob content is immutable by definition. Any mutable metadata (neighborhood, host lists, signatures) MUST be stored separately in a BlobHeader. Inline mutable headers are architecturally incompatible with content addressing.

    -

    BlobHeader Planned

    -

    Formal mutable structure replacing/extending CdnManifest. Stored and transmitted separately from blob data.

    +

    BlobHeader Complete

    +

    Mutable structure stored and transmitted separately from blob data. Carries engagement state, CDN metadata, and encrypted slots for private posts.

    BlobHeader {
    -    cid,                    // BLAKE3 hash of blob content
    -    author_nplus10,         // Author's N+10 (NodeId + 10 preferred peers)
    -    author_recent_posts,    // 25 previous + 25 following PostIds (neighborhood)
    -    upstream_nplus10,       // Upstream file source's N+10 (if not author)
    -    downstream_hosts,       // Up to min(100, floor(170MB / blob_size)) downstream hosts
    -    author_signature,       // ed25519 signature over author fields
    -    host_signature,         // ed25519 signature by current host
    +    post_id,                // PostId this header belongs to
    +    author,                 // Author NodeId
    +    reactions,              // Vec of public reactions (emoji + reactor NodeId + timestamp)
    +    comments,               // Vec of comments (text + author + timestamp + signature)
    +    policy,                 // Author-controlled comment/react policy
         updated_at,             // Timestamp of last header update
    +    thread_splits,          // Linked thread posts when comments exceed 16KB
    +    receipt_slots,          // Encrypted delivery/read/react receipt slots (private posts)
    +    comment_slots,          // Encrypted comment slots (private posts)
     }
    -

    Message types (41 total)

    +

    Message types (49 total)

    @@ -1023,6 +1025,8 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false } + +
    HexNameStreamPurpose
    0x01NodeListUpdateUniIncremental N1/N2 diff broadcast
    0xD6TcpPunchRequestBiAsk holder to punch TCP toward browser IP
    0xD7TcpPunchResultBiPunch result + HTTP address for redirect
    0xE0MeshKeepaliveUni30s connection heartbeat
    0xE1ReplicationRequestBiRequest peer to cache specific posts
    0xE2ReplicationResponseBiAccept/reject replication request

    Engagement propagation

    @@ -1031,9 +1035,34 @@ FAILURE: C → B → A: AnchorProbeResult { reachable: false }Push (real-time): On react/comment, the diff is sent to both downstream peers (CDN tree children) and the upstream peer (who we got the post from). Each intermediate node re-propagates both directions, excluding sender. This flows the diff up to the author and down to all holders.
  1. Auto downstream registration: Nodes that receive a post via pull sync or push notification automatically send PostDownstreamRegister (0xD3) to the sender, ensuring bidirectional diff flow.
  2. Pull (safety net): Every 5 minutes, the pull cycle requests BlobHeaderRequest (0xD1) with the local header timestamp. Peers respond with the full header only if theirs is newer. Additive merge — store_reaction upserts, store_comment inserts with ON CONFLICT DO NOTHING.
  3. +
  4. Tombstones: Deleted reactions and comments are not hard-deleted. Instead, a deleted_at timestamp is set on the record. Tombstones propagate via pull sync headers — when a peer receives a header with a tombstoned entry, it applies the deletion locally. This prevents deleted engagement from being re-introduced by peers that haven't yet received the deletion.
  5. Planned: Pull engagement from both upstream and downstream peers to catch missed diffs from either direction.
  6. +

    Device roles & bandwidth budgets Complete

    +

    Each node advertises its device role in InitialExchange, which determines its bandwidth budgets for replication (pulling posts to cache) and delivery (serving requests from peers):

    + + + + + +
    RoleReplication / hourDelivery / hour
    Intermittent (phones)100 MB1 GB
    Available (desktops)200 MB2 GB
    Persistent (anchors)200 MB1 GB
    + + +

    Active CDN replication Complete

    +

    All devices proactively replicate recent under-replicated posts to peers, not just passively serve on request:

    + +

    Connection rate limiting

    Incoming QUIC connections that fail authentication are rate-limited per source IP to prevent CPU exhaustion from rogue or stale nodes: