Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Move inbox to a global popover
Open rudolfs opened 1 year ago

Move the inbox into a popover that’s accessible globally.

  • refresh inbox counter and app icon badge every 3 seconds
  • show a button to load new notifications in case new ones come in when the popover is open
  • repo pinning and hiding
  • simplify backend into one call
  • load 100 notifications per repo and show a More button if there are more
  • Inbox 0 message when there are no notifications

check check-e2e check-unit-test

πŸ‘‰ Workflow runs πŸ‘‰ Branch on GitHub

36 files changed +1086 -851 5dacfd0e β†’ 3dd84e8d
modified crates/radicle-tauri/capabilities/default.json
@@ -4,19 +4,20 @@
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
-
    "core:path:default",
-
    "core:event:default",
-
    "core:window:default",
-
    "core:webview:default",
+
    "clipboard-manager:allow-write-text",
+
    "clipboard-manager:default",
    "core:app:default",
+
    "core:event:default",
    "core:image:default",
-
    "core:resources:default",
    "core:menu:default",
+
    "core:path:default",
+
    "core:resources:default",
    "core:tray:default",
-
    "shell:allow-open",
-
    "clipboard-manager:default",
-
    "clipboard-manager:allow-write-text",
+
    "core:webview:default",
+
    "core:window:allow-set-badge-count",
+
    "core:window:default",
+
    "dialog:default",
    "log:default",
-
    "dialog:default"
+
    "shell:allow-open"
  ]
}
modified crates/radicle-tauri/src/commands/inbox.rs
@@ -1,12 +1,9 @@
-
use std::collections::BTreeMap;
-

use radicle::identity;
use radicle::issue::cache::Issues;
use radicle::node;
use radicle::patch::cache::Patches;
use radicle::storage::{ReadRepository, ReadStorage};

-
use radicle_types::cobs::PaginatedQuery;
use radicle_types::domain::inbox::models::notification::{self, RepoGroupByItem};
use radicle_types::domain::inbox::service::Service;
use radicle_types::domain::inbox::traits::InboxService;
@@ -19,148 +16,184 @@ pub fn list_notifications(
    ctx: tauri::State<AppState>,
    sqlite_service: tauri::State<Service<Sqlite>>,
    params: notification::RepoGroupParams,
-
) -> Result<PaginatedQuery<RepoGroupByItem>, Error> {
+
) -> Result<notification::NotificationsByRepoList, Error> {
    let profile = &ctx.profile;
    let aliases = profile.aliases();
-
    let cursor = params.skip.unwrap_or(0);
-
    let take = params.take.unwrap_or(20);
-

-
    let all = sqlite_service.repo_group(params.clone())?;
-
    let more = cursor + take < all.len();
-
    let repo = profile.storage.repository(params.repo)?;
-
    let patches = profile.patches(&repo)?;
-
    let issues = profile.issues(&repo)?;
-

-
    let content = all
-
        .into_iter()
-
        .skip(cursor)
-
        .take(take)
-
        .map(|(qualified, n)| {
-
            let items = n
-
                .into_iter()
-
                .filter_map(|s| {
-
                    let update: notification::RefUpdate =
-
                        (qualified.clone().into_refstring(), s.new, s.old).into();
-
                    let update: radicle::storage::RefUpdate = update.into();
-
                    let kind =
-
                        node::notifications::NotificationKind::try_from(qualified.clone()).ok()?;
-

-
                    match kind {
-
                        node::notifications::NotificationKind::Cob { ref typed_id } => {
-
                            if typed_id.is_patch() {
-
                                let actions = notification::actions(
-
                                    typed_id.type_name.clone(),
-
                                    typed_id.id,
-
                                    update.old(),
-
                                    update.new(),
-
                                    &repo,
-
                                    &aliases,
-
                                )
-
                                .unwrap_or_default();
-

-
                                match patches.get(&typed_id.id) {
-
                                    Ok(Some(p)) => Some(notification::NotificationItem::Patch(
-
                                        notification::Patch {
-
                                            row_id: s.row_id,
-
                                            id: typed_id.id,
-
                                            update: update.into(),
-
                                            timestamp: s.timestamp,
-
                                            title: p.title().to_string(),
-
                                            status: (p.state().clone()).into(),
-
                                            actions,
-
                                        },
-
                                    )),
-
                                    Ok(None) => {
-
                                        log::error!("No patch found");
-
                                        None
-
                                    }
-
                                    Err(e) => {
-
                                        log::error!("{}", e);
-
                                        None
-
                                    }
-
                                }
-
                            } else if typed_id.is_issue() {
-
                                let actions = notification::actions(
-
                                    typed_id.type_name.clone(),
-
                                    typed_id.id,
-
                                    update.old(),
-
                                    update.new(),
-
                                    &repo,
-
                                    &aliases,
-
                                )
-
                                .unwrap_or_default();
-

-
                                match issues.get(&typed_id.id) {
-
                                    Ok(Some(i)) => Some(notification::NotificationItem::Issue(
-
                                        notification::Issue {
-
                                            row_id: s.row_id,
-
                                            id: typed_id.id,
-
                                            update: update.into(),
-
                                            timestamp: s.timestamp,
-
                                            title: i.title().to_string(),
-
                                            status: (*i.state()).into(),
-
                                            actions,
-
                                        },
-
                                    )),
-
                                    Ok(None) => {
-
                                        log::error!("No issue found");
-
                                        None
+
    let repos_with_groups = sqlite_service.repo_group(params.clone())?;
+

+
    let mut repo_counts = std::collections::HashMap::new();
+
    for (repo_id, count) in (sqlite_service.counts_by_repo()?).flatten() {
+
        repo_counts.insert(repo_id, count);
+
    }
+

+
    let take = if params.all.unwrap_or(false) {
+
        usize::MAX
+
    } else {
+
        params.take.unwrap_or(20)
+
    };
+

+
    let mut grouped_repos = std::collections::HashMap::new();
+
    for (repo_id, group) in repos_with_groups {
+
        grouped_repos
+
            .entry(repo_id)
+
            .or_insert_with(Vec::new)
+
            .extend(group);
+
    }
+

+
    let mut result = Vec::new();
+

+
    for (repo_id, all) in grouped_repos {
+
        let repo = match profile.storage.repository(repo_id) {
+
            Ok(r) => r,
+
            Err(e) => {
+
                log::error!("Failed to open repository {}: {}", repo_id, e);
+
                continue;
+
            }
+
        };
+

+
        let name = match repo.identity_doc() {
+
            Ok(identity::DocAt { doc, .. }) => match doc.project() {
+
                Ok(project) => project.name().to_string(),
+
                Err(_) => format!("Unknown project in {}", repo_id),
+
            },
+
            Err(_) => format!("Unknown project in {}", repo_id),
+
        };
+

+
        let patches = match profile.patches(&repo) {
+
            Ok(p) => p,
+
            Err(e) => {
+
                log::error!("Failed to get patches for {}: {}", repo_id, e);
+
                continue;
+
            }
+
        };
+
        let issues = match profile.issues(&repo) {
+
            Ok(i) => i,
+
            Err(e) => {
+
                log::error!("Failed to get issues for {}: {}", repo_id, e);
+
                continue;
+
            }
+
        };
+

+
        let content = all
+
            .into_iter()
+
            .take(take)
+
            .map(|(qualified, n)| {
+
                let items = n
+
                    .into_iter()
+
                    .filter_map(|s| {
+
                        let update: notification::RefUpdate =
+
                            (qualified.clone().into_refstring(), s.new, s.old).into();
+
                        let update: radicle::storage::RefUpdate = update.into();
+
                        let kind =
+
                            node::notifications::NotificationKind::try_from(qualified.clone())
+
                                .ok()?;
+

+
                        match kind {
+
                            node::notifications::NotificationKind::Cob { ref typed_id } => {
+
                                if typed_id.is_patch() {
+
                                    let actions = notification::actions(
+
                                        typed_id.type_name.clone(),
+
                                        typed_id.id,
+
                                        update.old(),
+
                                        update.new(),
+
                                        &repo,
+
                                        &aliases,
+
                                    )
+
                                    .unwrap_or_default();
+

+
                                    match patches.get(&typed_id.id) {
+
                                        Ok(Some(p)) => Some(notification::NotificationItem::Patch(
+
                                            notification::Patch {
+
                                                row_id: s.row_id,
+
                                                id: typed_id.id,
+
                                                update: update.into(),
+
                                                timestamp: s.timestamp,
+
                                                title: p.title().to_string(),
+
                                                status: (p.state().clone()).into(),
+
                                                actions,
+
                                                repo_id: Some(repo_id),
+
                                            },
+
                                        )),
+
                                        Ok(None) => {
+
                                            log::error!("No patch found");
+
                                            None
+
                                        }
+
                                        Err(e) => {
+
                                            log::error!("{}", e);
+
                                            None
+
                                        }
                                    }
-
                                    Err(e) => {
-
                                        log::error!("{}", e);
-
                                        None
+
                                } else if typed_id.is_issue() {
+
                                    let actions = notification::actions(
+
                                        typed_id.type_name.clone(),
+
                                        typed_id.id,
+
                                        update.old(),
+
                                        update.new(),
+
                                        &repo,
+
                                        &aliases,
+
                                    )
+
                                    .unwrap_or_default();
+

+
                                    match issues.get(&typed_id.id) {
+
                                        Ok(Some(i)) => Some(notification::NotificationItem::Issue(
+
                                            notification::Issue {
+
                                                row_id: s.row_id,
+
                                                id: typed_id.id,
+
                                                update: update.into(),
+
                                                timestamp: s.timestamp,
+
                                                title: i.title().to_string(),
+
                                                status: (*i.state()).into(),
+
                                                actions,
+
                                                repo_id: Some(repo_id),
+
                                            },
+
                                        )),
+
                                        Ok(None) => {
+
                                            log::error!("No issue found");
+
                                            None
+
                                        }
+
                                        Err(e) => {
+
                                            log::error!("{}", e);
+
                                            None
+
                                        }
                                    }
+
                                } else {
+
                                    None
                                }
-
                            } else {
-
                                None
                            }
+
                            _ => None,
                        }
-
                        _ => None,
-
                    }
-
                })
-
                .collect::<Vec<_>>();
-

-
            (qualified, items)
-
        })
-
        .filter(|(_, v)| !v.is_empty())
-
        .collect::<RepoGroupByItem>();
-

-
    Ok(PaginatedQuery {
-
        cursor,
-
        more,
-
        content,
-
    })
-
}
+
                    })
+
                    .collect::<Vec<_>>();

-
#[tauri::command]
-
pub fn count_notifications_by_repo(
-
    ctx: tauri::State<AppState>,
-
    inbox: tauri::State<Service<Sqlite>>,
-
) -> Result<BTreeMap<identity::RepoId, notification::NotificationCount>, Error> {
-
    let profile = &ctx.profile;
-
    let result = inbox
-
        .counts_by_repo()?
-
        .filter_map(|s| {
-
            let (rid, count) = s.ok()?;
-
            let repo = profile.storage.repository(rid).ok()?;
-
            let identity::DocAt { doc, .. } = repo.identity_doc().ok()?;
-
            let project = doc.project().ok()?;
-

-
            Some((
-
                rid,
-
                notification::NotificationCount {
-
                    rid,
-
                    name: project.name().to_string(),
-
                    count,
-
                },
-
            ))
-
        })
-
        .collect::<BTreeMap<identity::RepoId, notification::NotificationCount>>();
+
                items
+
            })
+
            .filter(|v| !v.is_empty())
+
            .collect::<RepoGroupByItem>();
+

+
        if !content.is_empty() {
+
            let count = repo_counts
+
                .get(&repo_id)
+
                .copied()
+
                .unwrap_or_else(|| content.iter().map(|items| items.len()).sum());
+

+
            result.push(notification::NotificationsByRepo {
+
                rid: repo_id,
+
                name,
+
                notifications: content,
+
                count,
+
            });
+
        }
+
    }

    Ok(result)
}

#[tauri::command]
+
pub fn notification_count(inbox: tauri::State<Service<Sqlite>>) -> Result<usize, Error> {
+
    inbox.notification_count().map_err(Error::from)
+
}
+

+
#[tauri::command]
pub fn clear_notifications(
    ctx: tauri::State<AppState>,
    params: notification::SetStatusNotifications,
modified crates/radicle-tauri/src/lib.rs
@@ -46,7 +46,7 @@ pub fn run() {
            cob::save_embed_to_disk,
            diff::get_diff,
            inbox::clear_notifications,
-
            inbox::count_notifications_by_repo,
+
            inbox::notification_count,
            inbox::list_notifications,
            profile::alias,
            profile::config,
modified crates/radicle-types/bindings/cob/inbox/Issue.ts
@@ -12,4 +12,5 @@ export type Issue = {
  timestamp: number;
  status: State;
  actions: Array<ActionWithAuthor<Action>>;
+
  repoId: string;
};
added crates/radicle-types/bindings/cob/inbox/NotificationsByRepo.ts
@@ -0,0 +1,9 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { NotificationItem } from "./NotificationItem";
+

+
export type NotificationsByRepo = {
+
  rid: string;
+
  name: string;
+
  notifications: Array<Array<NotificationItem>>;
+
  count: number;
+
};
modified crates/radicle-types/bindings/cob/inbox/Patch.ts
@@ -12,4 +12,5 @@ export type Patch = {
  title: string;
  status: State;
  actions: Array<ActionWithAuthor<Action>>;
+
  repoId: string;
};
modified crates/radicle-types/src/domain/inbox/models/notification.rs
@@ -31,10 +31,12 @@ pub struct NotificationRow {
    pub remote: storage::RemoteId,
    pub old: Option<git::Oid>,
    pub new: Option<git::Oid>,
+
    pub repo: Option<identity::RepoId>,
}

pub type RepoGroup = Vec<(git::Qualified<'static>, Vec<NotificationRow>)>;
-
pub type RepoGroupByItem = Vec<(git::Qualified<'static>, Vec<NotificationItem>)>;
+
pub type RepoGroupByItem = Vec<Vec<NotificationItem>>;
+

pub type CountByRepo = (identity::RepoId, usize);

#[derive(Clone, Debug)]
@@ -44,9 +46,9 @@ pub struct CountsByRepoParams {

#[derive(Clone, Debug, serde::Deserialize)]
pub struct RepoGroupParams {
-
    pub repo: identity::RepoId,
-
    pub skip: Option<usize>,
+
    pub repos: Option<Vec<identity::RepoId>>,
    pub take: Option<usize>,
+
    pub all: Option<bool>,
}

#[derive(Debug, thiserror::Error)]
@@ -96,18 +98,21 @@ where
    Ok(iter.filter_map(|a| a.ok()).collect::<Vec<_>>())
}

-
#[derive(Serialize, TS)]
+
#[derive(Serialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/inbox/")]
-
pub struct NotificationCount {
+
pub struct NotificationsByRepo {
    #[ts(as = "String")]
    pub rid: identity::RepoId,
    pub name: String,
+
    pub notifications: RepoGroupByItem,
    #[ts(type = "number")]
    pub count: usize,
}

+
pub type NotificationsByRepoList = Vec<NotificationsByRepo>;
+

#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type")]
@@ -132,6 +137,8 @@ pub struct Issue {
    pub timestamp: localtime::LocalTime,
    pub status: cobs::issue::State,
    pub actions: Vec<ActionWithAuthor<cobs::issue::Action>>,
+
    #[ts(as = "String")]
+
    pub repo_id: Option<identity::RepoId>,
}

#[derive(Debug, Serialize, TS, Deserialize)]
@@ -161,6 +168,8 @@ pub struct Patch {
    pub title: String,
    pub status: models::patch::State,
    pub actions: Vec<ActionWithAuthor<models::patch::Action>>,
+
    #[ts(as = "String")]
+
    pub repo_id: Option<identity::RepoId>,
}

/// Type of notification.
modified crates/radicle-types/src/domain/inbox/service.rs
@@ -1,3 +1,5 @@
+
use radicle::identity;
+

use crate::domain::inbox::models::notification::{
    CountByRepo, ListNotificationsError, RepoGroup, RepoGroupParams,
};
@@ -33,7 +35,14 @@ where
        self.inbox.counts_by_repo()
    }

-
    fn repo_group(&self, params: RepoGroupParams) -> Result<RepoGroup, ListNotificationsError> {
+
    fn notification_count(&self) -> Result<usize, ListNotificationsError> {
+
        self.inbox.notification_count()
+
    }
+

+
    fn repo_group(
+
        &self,
+
        params: RepoGroupParams,
+
    ) -> Result<Vec<(identity::RepoId, RepoGroup)>, ListNotificationsError> {
        self.inbox.repo_group(params)
    }
}
modified crates/radicle-types/src/domain/inbox/traits.rs
@@ -1,3 +1,5 @@
+
use radicle::identity;
+

use crate::domain::inbox::models::notification::{
    CountByRepo, ListNotificationsError, RepoGroupParams,
};
@@ -12,7 +14,12 @@ pub trait InboxStorage {
        ListNotificationsError,
    >;

-
    fn repo_group(&self, params: RepoGroupParams) -> Result<RepoGroup, ListNotificationsError>;
+
    fn notification_count(&self) -> Result<usize, ListNotificationsError>;
+

+
    fn repo_group(
+
        &self,
+
        params: RepoGroupParams,
+
    ) -> Result<Vec<(identity::RepoId, RepoGroup)>, ListNotificationsError>;
}

pub trait InboxService {
@@ -24,5 +31,11 @@ pub trait InboxService {
        ListNotificationsError,
    >;

-
    fn repo_group(&self, params: RepoGroupParams) -> Result<RepoGroup, ListNotificationsError>;
+
    /// Get the total notification count.
+
    fn notification_count(&self) -> Result<usize, ListNotificationsError>;
+

+
    fn repo_group(
+
        &self,
+
        params: RepoGroupParams,
+
    ) -> Result<Vec<(identity::RepoId, RepoGroup)>, ListNotificationsError>;
}
modified crates/radicle-types/src/outbound/sqlite.rs
@@ -133,12 +133,37 @@ impl InboxStorage for Sqlite {
        }))
    }

+
    fn notification_count(&self) -> Result<usize, notification::ListNotificationsError> {
+
        let stmt = self.db.prepare(
+
            "SELECT COUNT(DISTINCT substr(ref, 66)) as count
+
             FROM `repository-notifications`
+
             WHERE new NOT NULL AND (ref LIKE '%cobs/xyz.radicle.patch%' OR ref LIKE '%cobs/xyz.radicle.issue%')",
+
        )?;
+

+
        match stmt.into_iter().next() {
+
            Some(Ok(row)) => Ok(row.try_read::<i64, _>("count")? as usize),
+
            _ => Ok(0),
+
        }
+
    }
+

    fn repo_group(
        &self,
        params: notification::RepoGroupParams,
-
    ) -> Result<notification::RepoGroup, notification::ListNotificationsError> {
-
        let mut stmt = self.db.prepare(
-
            "SELECT ref, substr(ref, 66) ref_without_namespace,
+
    ) -> Result<
+
        Vec<(identity::RepoId, notification::RepoGroup)>,
+
        notification::ListNotificationsError,
+
    > {
+
        let repos_clause = match &params.repos {
+
            Some(repos) if !repos.is_empty() => {
+
                let placeholders: Vec<String> =
+
                    (1..=repos.len()).map(|i| format!("?{}", i)).collect();
+
                format!("WHERE repo IN ({})", placeholders.join(","))
+
            }
+
            _ => String::from(""),
+
        };
+

+
        let query = format!(
+
            "SELECT repo, ref, substr(ref, 66) ref_without_namespace,
                json_group_array(
                    json_object(
                        'row_id', rowid,
@@ -150,22 +175,52 @@ impl InboxStorage for Sqlite {
                ) as value,
                MAX(timestamp) AS latest_timestamp
            FROM 'repository-notifications'
-
            WHERE repo = ?
-
            GROUP BY ref_without_namespace
+
            {}
+
            GROUP BY repo, ref_without_namespace
            ORDER BY latest_timestamp DESC",
-
        )?;
-
        stmt.bind((1, &params.repo))?;
+
            repos_clause
+
        );

-
        stmt.into_iter()
-
            .map(|row| {
-
                let row = row?;
-
                let refstr = row.try_read::<&str, _>("ref")?;
-
                let value = row.try_read::<&str, _>("value")?;
-
                let items = serde_json::from_str::<Vec<notification::NotificationRow>>(value)?;
-
                let (_, reference) = git::parse_ref::<String>(refstr)?;
+
        let mut stmt = self.db.prepare(&query)?;

-
                Ok((reference.to_owned(), items))
-
            })
-
            .collect::<Result<notification::RepoGroup, notification::ListNotificationsError>>()
+
        if let Some(repos) = &params.repos {
+
            if !repos.is_empty() {
+
                for (i, repo) in repos.iter().enumerate() {
+
                    stmt.bind((i + 1, repo))?;
+
                }
+
            }
+
        }
+

+
        let mut result: Vec<(identity::RepoId, notification::RepoGroup)> = Vec::new();
+
        let mut current_repo: Option<identity::RepoId> = None;
+
        let mut current_group: notification::RepoGroup = Vec::new();
+

+
        for row_result in stmt.into_iter() {
+
            let row = row_result?;
+
            let repo_id = row.try_read::<identity::RepoId, _>("repo")?;
+
            let refstr = row.try_read::<&str, _>("ref")?;
+
            let value = row.try_read::<&str, _>("value")?;
+
            let items = serde_json::from_str::<Vec<notification::NotificationRow>>(value)?;
+
            let (_, reference) = git::parse_ref::<String>(refstr)?;
+

+
            if let Some(current) = current_repo {
+
                if current != repo_id {
+
                    result.push((current, std::mem::take(&mut current_group)));
+
                    current_repo = Some(repo_id);
+
                }
+
            } else {
+
                current_repo = Some(repo_id);
+
            }
+

+
            current_group.push((reference.to_owned(), items));
+
        }
+

+
        if let Some(repo) = current_repo {
+
            if !current_group.is_empty() {
+
                result.push((repo, current_group));
+
            }
+
        }
+

+
        Ok(result)
    }
}
modified crates/test-http-api/src/api.rs
@@ -17,7 +17,6 @@ use radicle_types::cobs::issue;
use radicle_types::cobs::issue::NewIssue;
use radicle_types::cobs::CobOptions;
use radicle_types::cobs::{self, FromRadicleAction};
-
use radicle_types::domain::inbox::models::notification::NotificationCount;
use radicle_types::domain::patch::models;
use radicle_types::domain::patch::service::Service;
use radicle_types::domain::patch::traits::PatchService;
@@ -59,10 +58,6 @@ pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/config", post(config_handler))
        .route("/authenticate", post(auth_handler))
-
        .route(
-
            "/count_notifications_by_repo",
-
            post(repo_count_notifications_handler),
-
        )
        .route("/repo_count", post(repo_count_handler))
        .route("/list_repos", post(repo_root_handler))
        .route("/repo_by_id", post(repo_handler))
@@ -128,10 +123,6 @@ async fn repo_count_handler(State(ctx): State<Context>) -> impl IntoResponse {
    Ok::<_, Error>(Json(repos))
}

-
async fn repo_count_notifications_handler() -> impl IntoResponse {
-
    Ok::<_, Error>(Json(Vec::<NotificationCount>::new()))
-
}
-

#[derive(Serialize, Deserialize)]
struct RepoBody {
    pub rid: identity::RepoId,
modified src/App.svelte
@@ -22,7 +22,6 @@
  import Auth from "@app/views/booting/Auth.svelte";
  import CreateIdentity from "@app/views/booting/CreateIdentity.svelte";
  import CreateIssue from "@app/views/repo/CreateIssue.svelte";
-
  import Inbox from "@app/views/home/Inbox.svelte";
  import Issue from "@app/views/repo/Issue.svelte";
  import Issues from "@app/views/repo/Issues.svelte";
  import Patch from "@app/views/repo/Patch.svelte";
@@ -108,8 +107,6 @@
  {/if}
{:else if $activeRouteStore.resource === "home"}
  <Repos {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "inbox"}
-
  <Inbox {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.home"}
  <RepoHome {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.createIssue"}
modified src/components/ConfirmClear.svelte
@@ -1,50 +1,36 @@
<script lang="ts">
-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  import Border from "./Border.svelte";
  import Button from "./Button.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import OutlineButton from "./OutlineButton.svelte";
-
  import Popover from "./Popover.svelte";

  interface Props {
    clear: () => void;
-
    subject: string;
+
    count: number;
  }

-
  const { clear, subject }: Props = $props();
+
  const { clear, count }: Props = $props();

-
  let popoverExpanded: boolean = $state(false);
+
  let closed: boolean = $state(true);
</script>

-
<Popover
-
  popoverPositionRight="0"
-
  popoverPositionTop="2.5rem"
-
  bind:expanded={popoverExpanded}>
-
  {#snippet toggle(onclick)}
-
    <NakedButton
-
      stylePadding="0 0.25rem"
-
      variant="ghost"
-
      {onclick}
-
      active={popoverExpanded}>
-
      <Icon name="broom-double" />
-
    </NakedButton>
-
  {/snippet}
-

-
  {#snippet popover()}
-
    <Border variant="ghost" stylePadding="1rem">
-
      <div class="global-flex txt-small">
-
        <div style:white-space="nowrap" style:margin-right="1rem">
-
          Clear all {subject} notifications?
-
        </div>
-
        <div class="global-flex" style:justify-content="space-between">
-
          <OutlineButton variant="ghost" onclick={closeFocused}>
-
            Cancel
-
          </OutlineButton>
-
          <Button variant="ghost" onclick={clear}>Clear all</Button>
-
        </div>
-
      </div>
-
    </Border>
-
  {/snippet}
-
</Popover>
+
{#if closed}
+
  <NakedButton
+
    stylePadding="0 0.25rem"
+
    variant="ghost"
+
    onclick={() => (closed = false)}>
+
    <Icon name="broom-double" />
+
  </NakedButton>
+
{:else}
+
  <div class="global-flex txt-small">
+
    <div class="global-flex" style:justify-content="space-between">
+
      <Button variant="ghost" onclick={clear}>
+
        <Icon name="broom-double" />
+
        Clear all {count}
+
      </Button>
+
      <OutlineButton variant="ghost" onclick={() => (closed = true)}>
+
        <Icon name="cross" />Cancel
+
      </OutlineButton>
+
    </div>
+
  </div>
+
{/if}
modified src/components/Header.svelte
@@ -13,6 +13,7 @@

  import Avatar from "@app/components/Avatar.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import InboxPopover from "@app/components/InboxPopover.svelte";
  import InfoButton from "@app/components/InfoButton.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
  import NodeStatusButton from "@app/components/NodeStatusButton.svelte";
@@ -29,10 +30,9 @@
  interface Props {
    config: Config;
    center?: Snippet;
+
    notificationCount: number;
  }

-
  const { center, config }: Props = $props();
-

  onMount(async () => {
    try {
      await checkRadicleCLI();
@@ -46,6 +46,8 @@
      firstLaunchStorage.value = false;
    }
  });
+

+
  const { center, notificationCount, config }: Props = $props();
</script>

<style>
@@ -71,6 +73,7 @@
    flex-direction: column;
    width: 100%;
    row-gap: 8px;
+
    z-index: 50;
  }
  .top-row {
    display: flex;
@@ -87,7 +90,7 @@
          variant="ghost"
          active={$activeRouteStore.resource === "home"}
          onclick={() => {
-
            void router.push({ resource: "home" });
+
            void router.push({ resource: "home", activeTab: "all" });
          }}
          stylePadding="0 4px">
          <Avatar publicKey={config.publicKey} />
@@ -115,13 +118,7 @@
      <div class="global-flex">
        <InfoButton {config} />
        <NodeStatusButton />
-
        <NakedButton
-
          variant="ghost"
-
          stylePadding="0 4px"
-
          active={$activeRouteStore.resource === "inbox"}
-
          onclick={() => router.push({ resource: "inbox" })}>
-
          <Icon name="inbox" />
-
        </NakedButton>
+
        <InboxPopover {notificationCount} />
      </div>
    </div>
  </div>
modified src/components/HomeSidebar.svelte
@@ -1,25 +1,19 @@
<script lang="ts">
-
  import type { HomeInboxTab, HomeReposTab } from "@app/lib/router/definitions";
-
  import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
  import type { RepoCount } from "@bindings/repo/RepoCount";
-

-
  import sum from "lodash/sum";
+
  import type { HomeReposTab } from "@app/views/home/router";

  import * as router from "@app/lib/router";
+

  import Border from "./Border.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "./Link.svelte";
  import Settings from "@app/components/Settings.svelte";

  interface Props {
-
    activeTab:
-
      | { type: "inbox"; repo?: HomeInboxTab }
-
      | { type: "repos"; filter?: HomeReposTab };
-
    notificationCount: Map<string, NotificationCount>;
+
    activeTab: HomeReposTab;
    repoCount: RepoCount;
  }

-
  const { notificationCount, repoCount, activeTab }: Props = $props();
+
  const { activeTab, repoCount }: Props = $props();
</script>

<style>
@@ -50,163 +44,76 @@
    background-color: var(--color-background-default);
    font-weight: var(--font-weight-semibold);
  }
-
  .highlight {
-
    color: var(--color-foreground-contrast);
-
  }
</style>

<div class="container">
  <div>
-
    <div style:margin-bottom="1rem">
-
      {#if activeTab.type === "inbox"}
-
        <Border
-
          styleCursor="pointer"
-
          variant="ghost"
-
          styleFlexDirection="column"
-
          styleGap="2px"
-
          styleBackgroundColor="var(--color-background-float)">
-
          <!-- svelte-ignore a11y_click_events_have_key_events -->
-
          <!-- svelte-ignore a11y_no_static_element_interactions -->
-
          <div
-
            class="tab"
-
            class:active={!activeTab.repo}
-
            onclick={() => router.push({ resource: "inbox" })}>
-
            <div class="global-flex"><Icon name="inbox" />Inbox</div>
-
            <div class="global-counter">
-
              {sum(Array.from(notificationCount.values()).map(c => c.count))}
-
            </div>
-
          </div>
-
          {#each notificationCount.entries() as [_, n]}
-
            <!-- svelte-ignore a11y_click_events_have_key_events -->
-
            <!-- svelte-ignore a11y_no_static_element_interactions -->
-
            <div
-
              class="tab"
-
              onclick={() =>
-
                router.push({
-
                  resource: "inbox",
-
                  activeTab: n,
-
                })}
-
              class:active={activeTab.repo?.rid === n.rid}>
-
              <div class="global-flex">
-
                <Icon name="repo" />{n.name}
-
              </div>
-
              <div
-
                class="global-counter"
-
                class:highlight={activeTab.repo?.rid === n.rid}>
-
                {n.count}
-
              </div>
-
            </div>
-
          {/each}
-
        </Border>
-
      {:else}
-
        <!-- svelte-ignore a11y_no_static_element_interactions -->
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <div
-
          class="tab"
-
          style:cursor="pointer"
-
          onclick={() => router.push({ resource: "inbox" })}
-
          style:color="var(--color-foreground-contrast)"
-
          style:padding-left="12px">
-
          <div class="global-flex"><Icon name="inbox" />Inbox</div>
-
          <div class="global-counter">
-
            {sum(Array.from(notificationCount.values()).map(c => c.count))}
-
          </div>
-
        </div>
-
      {/if}
-
    </div>
-

-
    {#if activeTab.type === "repos"}
-
      <Border
-
        styleCursor="pointer"
-
        variant="ghost"
-
        styleFlexDirection="column"
-
        styleGap="2px"
-
        styleBackgroundColor="var(--color-background-float)">
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <!-- svelte-ignore a11y_no_static_element_interactions -->
-
        <div
-
          class="tab txt-small"
-
          class:active={!activeTab.filter}
-
          onclick={() => router.push({ resource: "home" })}>
-
          <div class="global-flex"><Icon name="repo" />Repositories</div>
-
          <div class="global-counter">
-
            {repoCount.total}
-
          </div>
+
    <Border
+
      styleCursor="pointer"
+
      variant="ghost"
+
      styleFlexDirection="column"
+
      styleGap="2px"
+
      styleBackgroundColor="var(--color-background-float)">
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <!-- svelte-ignore a11y_no_static_element_interactions -->
+
      <div
+
        class="tab txt-small"
+
        class:active={activeTab === "all"}
+
        onclick={() => router.push({ resource: "home", activeTab: "all" })}>
+
        <div class="global-flex"><Icon name="repo" />Repositories</div>
+
        <div class="global-counter">
+
          {repoCount.total}
        </div>
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <!-- svelte-ignore a11y_no_static_element_interactions -->
-
        <div
-
          class="tab"
-
          class:active={activeTab.filter === "delegate"}
-
          onclick={() =>
-
            router.push({
-
              resource: "home",
-
              activeTab: "delegate",
-
            })}>
-
          <div class="global-flex">
-
            <Icon name="delegate" />
-
            <div>Delegate</div>
-
          </div>
-
          <div class="global-counter">{repoCount.delegate}</div>
-
        </div>
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <!-- svelte-ignore a11y_no_static_element_interactions -->
-
        <div
-
          class="tab"
-
          class:active={activeTab.filter === "contributor"}
-
          onclick={() =>
-
            router.push({
-
              resource: "home",
-
              activeTab: "contributor",
-
            })}>
-
          <div class="global-flex">
-
            <Icon name="user" />
-
            <div>Contributor</div>
-
          </div>
-
          <div class="global-counter">{repoCount.contributor}</div>
+
      </div>
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <!-- svelte-ignore a11y_no_static_element_interactions -->
+
      <div
+
        class="tab"
+
        class:active={activeTab === "delegate"}
+
        onclick={() =>
+
          router.push({
+
            resource: "home",
+
            activeTab: "delegate",
+
          })}>
+
        <div class="global-flex">
+
          <Icon name="delegate" />
+
          <div>Delegate</div>
        </div>
-
        <!-- svelte-ignore a11y_click_events_have_key_events -->
-
        <!-- svelte-ignore a11y_no_static_element_interactions -->
-
        <div
-
          class="tab"
-
          class:active={activeTab.filter === "private"}
-
          onclick={() =>
-
            router.push({
-
              resource: "home",
-
              activeTab: "private",
-
            })}>
-
          <div class="global-flex">
-
            <Icon name="lock" />
-
            <div>Private</div>
-
          </div>
-
          <div class="global-counter">{repoCount.private}</div>
+
        <div class="global-counter">{repoCount.delegate}</div>
+
      </div>
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <!-- svelte-ignore a11y_no_static_element_interactions -->
+
      <div
+
        class="tab"
+
        class:active={activeTab === "contributor"}
+
        onclick={() =>
+
          router.push({
+
            resource: "home",
+
            activeTab: "contributor",
+
          })}>
+
        <div class="global-flex">
+
          <Icon name="user" />
+
          <div>Contributor</div>
        </div>
-
      </Border>
-
    {:else}
-
      <Border
-
        styleBackgroundColor="var(--color-background-float)"
-
        variant="float">
-
        <Link
-
          styleWidth="100%"
-
          underline={false}
-
          route={{
+
        <div class="global-counter">{repoCount.contributor}</div>
+
      </div>
+
      <!-- svelte-ignore a11y_click_events_have_key_events -->
+
      <!-- svelte-ignore a11y_no_static_element_interactions -->
+
      <div
+
        class="tab"
+
        class:active={activeTab === "private"}
+
        onclick={() =>
+
          router.push({
            resource: "home",
-
          }}>
-
          <div
-
            style:justify-content="space-between"
-
            style:width="100%"
-
            class="tab">
-
            <div class="global-flex">
-
              <Icon name="repo" />
-
              Repositories
-
            </div>
-
            <div class="global-counter">
-
              {repoCount.total}
-
            </div>
-
          </div>
-
        </Link>
-
      </Border>
-
    {/if}
+
            activeTab: "private",
+
          })}>
+
        <div class="global-flex">
+
          <Icon name="lock" />
+
          <div>Private</div>
+
        </div>
+
        <div class="global-counter">{repoCount.private}</div>
+
      </div>
+
    </Border>
  </div>

  <Settings
modified src/components/Icon.svelte
@@ -39,6 +39,7 @@
      | "expand"
      | "expand-panel"
      | "eye"
+
      | "eye-closed"
      | "face"
      | "file"
      | "filter"
@@ -61,6 +62,8 @@
      | "patch-draft"
      | "patch-merged"
      | "pen"
+
      | "pin"
+
      | "pin-hollow"
      | "plus"
      | "reply"
      | "repo"
@@ -69,6 +72,7 @@
      | "seedling-filled"
      | "settings"
      | "sun"
+
      | "thumb-up"
      | "user"
      | "warning";
  }
@@ -562,6 +566,39 @@
    <path d="M2.00002 9L2.00002 8H3.00002V9L2.00002 9Z" />
    <path d="M3.00002 7L3.00002 8H2.00002L2.00002 7H3.00002Z" />
    <path d="M13 9V8H14V9L13 9Z" />
+
  {:else if name === "eye-closed"}
+
    <path d="M10 5L8 5V4L10 4V5Z" />
+
    <path d="M6 11H8V12H6V11Z" />
+
    <path d="M7 7H9L9 6L7 6V7Z" />
+
    <path d="M9 9L8 9V10H9V9Z" />
+
    <path d="M4 6V7H3L3 6H4Z" />
+
    <path d="M12 10L12 9L13 9L13 10L12 10Z" />
+
    <path d="M11 6H10L10 5L11 5V6Z" />
+
    <path d="M4 10H6L6 11L4 11L4 10Z" />
+
    <path d="M6 6H4V5L6 5L6 6Z" />
+
    <path d="M10 10L12 10V11H10L10 10Z" />
+
    <path d="M12 7V6L13 6L13 7L12 7Z" />
+
    <path d="M4 9V10L3 10L3 9L4 9Z" />
+
    <path d="M8 5L6 5L6 4L8 4V5Z" />
+
    <path d="M8 11L10 11V12L8 12V11Z" />
+
    <path d="M6 8V7H7V8L6 8Z" />
+
    <path d="M10 8V9H9V8L10 8Z" />
+
    <path d="M7 8L7 9H6V8L7 8Z" />
+
    <path d="M14 7V8H13V7L14 7Z" />
+
    <path d="M2 9L2 8H3L3 9H2Z" />
+
    <path d="M3 7L3 8H2L2 7H3Z" />
+
    <path d="M13 9V8H14L14 9H13Z" />
+
    <path d="M13 2L14 2V3H13V2Z" />
+
    <path d="M12 3L13 3V4H12V3Z" />
+
    <path d="M11 4L12 4L12 5L11 5L11 4Z" />
+
    <path d="M10 5L11 5V6H10L10 5Z" />
+
    <path d="M9 6L10 6V7H9L9 6Z" />
+
    <path d="M8 7L9 7V8L8 8V7Z" />
+
    <path d="M7 8H8V9L7 9L7 8Z" />
+
    <path d="M6 9H7L7 10L6 10L6 9Z" />
+
    <path d="M4 11L5 11L5 12H4L4 11Z" />
+
    <path d="M3 12H4V13H3V12Z" />
+
    <path d="M2 13H3L3 14H2V13Z" />
  {:else if name === "face"}
    <path d="M6 13H8V14H6V13Z" />
    <path d="M10 13L8 13V14L10 14V13Z" />
@@ -966,6 +1003,29 @@
    <path d="M2 11H3V12H2V11Z" />
    <path d="M3 12H4V13H3V12Z" />
    <path d="M3 13H4V14H3V13Z" />
+
  {:else if name === "pin"}
+
    <path d="M3.5 2H12.5V3H3.5V2Z" />
+
    <path d="M3.5 9H12.5V10H3.5V9Z" />
+
    <path d="M7.5 10H8.5V14H7.5V10Z" />
+
    <path d="M5.5 4H6.5V8H5.5V4Z" />
+
    <path d="M10.5 4H9.5V8H10.5V4Z" />
+
    <path d="M4.5 3H5.5V4H4.5V3Z" />
+
    <path d="M11.5 3H10.5V4H11.5V3Z" />
+
    <path d="M4.5 8H5.5V9H4.5V8Z" />
+
    <path d="M11.5 8H10.5V9H11.5V8Z" />
+
    <path d="M5.5 3H8.5V8H5.5V3Z" />
+
    <path d="M5.5 8H10.5V9H5.5V8Z" />
+
  {:else if name === "pin-hollow"}
+
    <path d="M3.5 2H12.5V3H3.5V2Z" />
+
    <path d="M3.5 9H12.5V10H3.5V9Z" />
+
    <path d="M7.5 10H8.5V14H7.5V10Z" />
+
    <path d="M5.5 4H6.5V8H5.5V4Z" />
+
    <path d="M10.5 4H9.5V8H10.5V4Z" />
+
    <path d="M4.5 3H5.5V4H4.5V3Z" />
+
    <path d="M11.5 3H10.5V4H11.5V3Z" />
+
    <path d="M4.5 8H5.5V9H4.5V8Z" />
+
    <path d="M11.5 8H10.5V9H11.5V8Z" />
+
    <path d="M8.5 4H9.5V5H8.5V4Z" />
  {:else if name === "plus"}
    <path d="M9 14H7L7 2L9 2L9 14Z" />
    <path d="M14 7V9L2 9L2 7L14 7Z" />
@@ -1134,6 +1194,25 @@
    <path d="M6 9L7 9L7 10H6V9Z" />
    <path d="M9 9L10 9L10 10H9L9 9Z" />
    <path d="M9 6H10V7H9V6Z" />
+
  {:else if name === "thumb-up"}
+
    <path d="M4 13L11 13V14L4 14V13Z" />
+
    <path d="M3 11H7V12H3L3 11Z" />
+
    <path d="M3 9H7V10H3V9Z" />
+
    <path d="M12 12L12 5L13 5L13 12H12Z" />
+
    <path d="M11 5L11 4H12V5L11 5Z" />
+
    <path d="M10 4V2L11 2V4L10 4Z" />
+
    <path d="M9 5V3H10V5L9 5Z" />
+
    <path d="M9 8L9 4L10 4V8H9Z" />
+
    <path d="M3 8L3 7L7 7V8H3Z" />
+
    <path d="M4 6V5L9 5V6L4 6Z" />
+
    <path d="M3 12L3 11H4L4 12H3Z" />
+
    <path d="M3 13L3 6H4L4 13L3 13Z" />
+
    <path d="M9 2L10 2V3H9V2Z" />
+
    <path d="M11 12H12L12 13L11 13L11 12Z" />
+
    <path d="M7 8H8V9H7V8Z" />
+
    <path d="M7 6H8V7H7L7 6Z" />
+
    <path d="M7 10L8 10V11L7 11L7 10Z" />
+
    <path d="M7 12L8 12L8 13H7L7 12Z" />
  {:else if name === "user"}
    <path d="M5 3H6V4H5V3Z" />
    <path d="M5 6L5 8H4V6H5Z" />
added src/components/InboxPopover.svelte
@@ -0,0 +1,147 @@
+
<script lang="ts">
+
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
+

+
  import { getCurrentWindow } from "@tauri-apps/api/window";
+

+
  import { onMount } from "svelte";
+

+
  import { dynamicInterval } from "@app/lib/interval";
+
  import { invoke } from "@app/lib/invoke";
+

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Inbox from "@app/views/home/Inbox.svelte";
+
  import OutlineButton from "./OutlineButton.svelte";
+
  import Popover from "./Popover.svelte";
+

+
  interface Props {
+
    notificationCount: number;
+
  }
+

+
  let { notificationCount }: Props = $props();
+

+
  let notificationPopoverExpaneded: boolean = $state(false);
+
  let buttonActive: boolean = $state(false);
+

+
  $effect(() => {
+
    if (notificationPopoverExpaneded === false) {
+
      buttonActive = false;
+
    }
+
  });
+

+
  onMount(async () => {
+
    await loadCounter();
+
  });
+

+
  dynamicInterval("auth", loadCounter, 3_000);
+

+
  async function loadCounter() {
+
    notificationCount = await invoke<number>("notification_count");
+
    if (window.__TAURI_INTERNALS__) {
+
      await getCurrentWindow().setBadgeCount(
+
        notificationCount === 0 ? undefined : notificationCount,
+
      );
+
    }
+
  }
+

+
  let notificationsByRepo: NotificationsByRepo[] = $state([]);
+

+
  async function loadNotifications() {
+
    notificationsByRepo = await invoke<NotificationsByRepo[]>(
+
      "list_notifications",
+
      { params: { take: 100 } },
+
    );
+
  }
+

+
  async function clearAll() {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "all" },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function clearByRepo(rid: string) {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "repo", content: rid },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function clearByIds(ids: string[]) {
+
    try {
+
      await invoke("clear_notifications", {
+
        params: { type: "ids", content: ids },
+
      });
+
    } catch (error) {
+
      console.error("Clearing notifications failed", error);
+
    } finally {
+
      await loadCounter();
+
      await loadNotifications();
+
    }
+
  }
+

+
  async function showAll(rid: string) {
+
    const allNotificationsForRepo = await invoke<NotificationsByRepo[]>(
+
      "list_notifications",
+
      { params: { repos: [rid] } },
+
    );
+
    notificationsByRepo = [
+
      ...notificationsByRepo.filter(r => r.rid !== rid),
+
      ...allNotificationsForRepo,
+
    ];
+
  }
+
</script>
+

+
<Popover
+
  popoverPositionRight="0"
+
  popoverPositionTop="3rem"
+
  bind:expanded={notificationPopoverExpaneded}>
+
  {#snippet toggle(onclick)}
+
    <OutlineButton
+
      onclick={async () => {
+
        buttonActive = true;
+
        await loadNotifications();
+
        onclick();
+
      }}
+
      variant={notificationCount && notificationCount > 0
+
        ? "secondary"
+
        : "ghost"}
+
      active={buttonActive}>
+
      <Icon name="inbox" />
+
      {#if notificationCount !== undefined && notificationCount > 0}
+
        {notificationCount}
+
      {/if}
+
    </OutlineButton>
+
  {/snippet}
+

+
  {#snippet popover()}
+
    <Border
+
      variant="ghost"
+
      styleWidth="40rem"
+
      stylePadding="1rem"
+
      styleAlignItems="flex-start"
+
      styleOverflow="auto"
+
      styleMaxHeight="calc(100vh - 5rem)">
+
      <Inbox
+
        {clearAll}
+
        {clearByIds}
+
        {clearByRepo}
+
        loadNew={loadNotifications}
+
        {notificationCount}
+
        {notificationsByRepo}
+
        {showAll} />
+
    </Border>
+
  {/snippet}
+
</Popover>
modified src/components/NotificationTeaser.svelte
@@ -27,7 +27,7 @@
    rid: string;
    kind: "issue" | "patch";
    oid: string;
-
    clearByIds: (rid: string, ids: string[]) => Promise<void>;
+
    clearByIds: (ids: string[]) => Promise<void>;
    notificationItems: NotificationItem[];
    selected?: boolean;
  }
@@ -201,7 +201,11 @@
        {/if}
        <div class="txt-small">
          {#each uniqueActions as action}
-
            <div class="global-flex" style:gap="0.25rem" style:height="2rem">
+
            <div
+
              class="global-flex"
+
              style:gap="0.25rem"
+
              style:min-height="2rem"
+
              style:flex-wrap="wrap">
              <NodeId {...authorForNodeId(action.items[0].author)} />
              <span>{@html action.summary}</span>
              <span>{formatTimestamp(action.items[0].timestamp)}</span>
@@ -216,10 +220,7 @@
        variant="ghost"
        onclick={e => {
          e.stopPropagation();
-
          void clearByIds(
-
            rid,
-
            notificationItems.map(n => n.rowId),
-
          );
+
          void clearByIds(notificationItems.map(n => n.rowId));
        }}>
        <Icon name={clearIcon} />
      </NakedButton>
added src/components/NotificationsByRepo.svelte
@@ -0,0 +1,153 @@
+
<script lang="ts">
+
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
+

+
  import Button from "./Button.svelte";
+
  import ConfirmClear from "./ConfirmClear.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import NotificationTeaser from "./NotificationTeaser.svelte";
+
  import NakedButton from "./NakedButton.svelte";
+

+
  interface Props {
+
    clearByIds: (ids: string[]) => Promise<void>;
+
    clearByRepo: (rid: string) => Promise<void>;
+
    count: number;
+
    groupedNotifications: NotificationsByRepo["notifications"];
+
    hidden: boolean;
+
    name: string;
+
    pinned: boolean;
+
    rid: string;
+
    showAll: (rid: string) => Promise<void>;
+
    toggleHide: (rid: string) => void;
+
    togglePin: (rid: string) => void;
+
  }
+

+
  const {
+
    clearByIds,
+
    clearByRepo,
+
    count,
+
    groupedNotifications,
+
    hidden,
+
    name,
+
    pinned,
+
    rid,
+
    showAll,
+
    toggleHide,
+
    togglePin,
+
  }: Props = $props();
+
</script>
+

+
<style>
+
  .header {
+
    display: flex;
+
    align-items: center;
+
    padding-right: 1rem;
+
    width: 100%;
+
    min-height: 2rem;
+
    gap: 0.75rem;
+
  }
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 2px;
+
  }
+
  .action-buttons {
+
    display: flex;
+
    gap: 0.25rem;
+
  }
+
  .clear-repo {
+
    margin-left: auto;
+
  }
+
  .action-buttons,
+
  .clear-repo {
+
    display: none;
+
  }
+
  .header:hover .action-buttons,
+
  .header:hover .clear-repo {
+
    display: flex;
+
  }
+
</style>
+

+
<div>
+
  <div
+
    class="header"
+
    class:txt-missing={hidden}
+
    style:margin-bottom={!hidden ? "1rem" : undefined}>
+
    <span class="txt-bold">
+
      {name}
+
    </span>
+
    {count}
+
    <div
+
      class="action-buttons"
+
      style:display={pinned || hidden ? "flex" : undefined}>
+
      {#if !hidden}
+
        <NakedButton
+
          variant="ghost"
+
          stylePadding="0 0.25rem"
+
          onclick={() => {
+
            togglePin(rid);
+
          }}>
+
          <Icon name={pinned ? "pin" : "pin-hollow"} />
+
        </NakedButton>
+
      {/if}
+
      {#if !pinned}
+
        <NakedButton
+
          variant="ghost"
+
          stylePadding="0 0.25rem"
+
          onclick={() => {
+
            toggleHide(rid);
+
          }}>
+
          <Icon name={hidden ? "eye-closed" : "eye"} />
+
        </NakedButton>
+
      {/if}
+
    </div>
+
    {#if count > 0 && !hidden}
+
      <div class="clear-repo">
+
        <ConfirmClear
+
          {count}
+
          clear={() => {
+
            void clearByRepo(rid);
+
          }} />
+
      </div>
+
    {/if}
+
  </div>
+

+
  {#if !hidden}
+
    <div class="container">
+
      {#if groupedNotifications.length > 0}
+
        {#each groupedNotifications as notificationGroup}
+
          <NotificationTeaser
+
            {clearByIds}
+
            {rid}
+
            kind={notificationGroup[0].type}
+
            oid={notificationGroup[0].id}
+
            notificationItems={notificationGroup} />
+
        {/each}
+
      {:else}
+
        <div
+
          class="global-flex"
+
          style:height="100%"
+
          style:align-items="center"
+
          style:justify-content="center">
+
          <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
+
            <Icon name="none" />
+
            No notifications.
+
          </div>
+
        </div>
+
      {/if}
+
    </div>
+

+
    {#if groupedNotifications.length > 0 && groupedNotifications.length < count}
+
      <div style:width="100%" style:margin-top="1rem">
+
        <div style:width="7rem" style:margin="auto">
+
          <Button
+
            variant="ghost"
+
            onclick={async () => {
+
              await showAll(rid);
+
            }}>
+
            <div style:width="100%" style:text-align="center">Show all</div>
+
          </Button>
+
        </div>
+
      </div>
+
    {/if}
+
  {/if}
+
</div>
modified src/components/OutlineButton.svelte
@@ -28,7 +28,9 @@
      `--button-color-2: var(--color-fill-${variant}-hover);` +
      `--button-color-3: var(--color-fill-${variant}-shade);` +
      // The ghost colors are called --color-fill-counter and --color-fill-counter-emphasized.
-
      `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter)`,
+
      `--button-color-4: var(--color-fill${variant === "ghost" ? "" : `-${variant}`}-counter);` +
+
      `--text-color-hover: ${variant === "ghost" ? "var(--color-foreground-contrast)" : "var(--color-foreground-white)"};` +
+
      `--text-color-active: ${variant === "ghost" ? "var(--color-foreground-emphasized)" : "var(--color-foreground-white)"};`,
  );
</script>

@@ -229,8 +231,12 @@
    background-color: var(--button-color-1);
  }

+
  .container:hover:not(.disabled) {
+
    color: var(--text-color-hover);
+
  }
+

  .container.active:not(.disabled) {
-
    color: var(--color-foreground-emphasized);
+
    color: var(--text-color-active);
  }

  .container.disabled {
deleted src/components/RepoNotifications.svelte
@@ -1,84 +0,0 @@
-
<script lang="ts">
-
  import type { HomeInboxTab } from "@app/lib/router/definitions";
-
  import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
-

-
  import * as router from "@app/lib/router";
-

-
  import Button from "./Button.svelte";
-
  import ConfirmClear from "./ConfirmClear.svelte";
-
  import NotificationTeaser from "./NotificationTeaser.svelte";
-

-
  interface Props {
-
    all?: boolean;
-
    clearByIds: (rid: string, ids: string[]) => Promise<void>;
-
    clearByRepo: (rid: string) => Promise<void>;
-
    repo: HomeInboxTab;
-
    items: [string, NotificationItem[]][];
-
    more: boolean;
-
  }
-

-
  const {
-
    all = false,
-
    more,
-
    clearByRepo,
-
    clearByIds,
-
    repo,
-
    items,
-
  }: Props = $props();
-
</script>
-

-
<style>
-
  .header {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    padding-right: 1rem;
-
  }
-
  .container {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 2px;
-
  }
-
</style>
-

-
{#if Object.entries(items).length > 0}
-
  <div class="header">
-
    <div class="global-flex" style:margin="1rem 0">
-
      <span class="txt-bold">
-
        {repo.name}
-
      </span>
-
      {repo.count}
-
    </div>
-
    <ConfirmClear
-
      subject={repo.name}
-
      clear={() => {
-
        void clearByRepo(repo.rid);
-
      }} />
-
  </div>
-
{/if}
-

-
<div class="container">
-
  {#each items.sort((a, b) => b[1][0].timestamp - a[1][0].timestamp) as [_, notificationItems]}
-
    <NotificationTeaser
-
      {clearByIds}
-
      rid={repo.rid}
-
      kind={notificationItems[0].type}
-
      oid={notificationItems[0].id}
-
      {notificationItems} />
-
  {/each}
-
</div>
-
{#if all === false && more}
-
  <div style:width="100%" style:margin-top="1rem">
-
    <div style:width="7rem" style:margin="auto">
-
      <Button
-
        variant="ghost"
-
        onclick={() =>
-
          router.push({
-
            resource: "inbox",
-
            activeTab: repo,
-
          })}>
-
        <div style:width="100%" style:text-align="center">See all</div>
-
      </Button>
-
    </div>
-
  </div>
-
{/if}
modified src/lib/auth.svelte.ts
@@ -22,7 +22,7 @@ export async function checkAuth() {
      import.meta.env.VITE_AUTH_LONG_DELAY || 30_000,
    );
    if (get(router.activeRouteStore).resource === "booting") {
-
      void router.push({ resource: "home" });
+
      void router.push({ resource: "home", activeTab: "all" });
    }
  } catch (err) {
    const error = err as ErrorWrapper;
modified src/lib/router.ts
@@ -47,7 +47,7 @@ async function navigateToUrl(
    await navigate(action, route);
  } else {
    console.error("Could not resolve route for URL: ", url);
-
    await navigate(action, { resource: "home" });
+
    await navigate(action, { resource: "home", activeTab: "all" });
  }
}

@@ -105,11 +105,9 @@ function urlToRoute(url: URL): Route | null {
    case "": {
      return {
        resource: "home",
+
        activeTab: "all",
      };
    }
-
    case "inbox": {
-
      return { resource: "inbox" };
-
    }
    case "repos": {
      return repoUrlToRoute(segments, url.searchParams);
    }
@@ -122,8 +120,6 @@ function urlToRoute(url: URL): Route | null {
export function routeToPath(route: Route): string {
  if (route.resource === "home") {
    return "/";
-
  } else if (route.resource === "inbox") {
-
    return "/inbox";
  } else if (
    route.resource === "repo.home" ||
    route.resource === "repo.createIssue" ||
modified src/lib/router/definitions.ts
@@ -1,14 +1,7 @@
-
import type { Config } from "@bindings/config/Config";
-
import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
-
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
-
import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
-
import type { RepoInfo } from "@bindings/repo/RepoInfo";
-
import type { RepoCount } from "@bindings/repo/RepoCount";
+
import type { LoadedHomeRoute, HomeRoute } from "@app/views/home/router";
import type { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router";

-
import { invoke } from "@app/lib/invoke";
-
import { SvelteMap } from "svelte/reactivity";
-

+
import { loadHome } from "@app/views/home/router";
import {
  loadCreateIssue,
  loadIssue,
@@ -18,142 +11,19 @@ import {
  loadRepoHome,
} from "@app/views/repo/router";

-
export type HomeReposTab = "delegate" | "private" | "contributor";
-

-
export interface HomeInboxTab {
-
  rid: string;
-
  name: string;
-
  count: number;
-
}
-

interface BootingRoute {
  resource: "booting";
}

-
interface HomeRoute {
-
  resource: "home";
-
  activeTab?: HomeReposTab;
-
}
-

-
interface InboxRoute {
-
  resource: "inbox";
-
  activeTab?: HomeInboxTab;
-
}
-

-
interface LoadedInboxRoute {
-
  resource: "inbox";
-
  params: {
-
    activeTab?: HomeInboxTab;
-
    repoCount: RepoCount;
-
    notificationCount: SvelteMap<string, NotificationCount>;
-
    notifications: SvelteMap<
-
      string,
-
      {
-
        repo: HomeInboxTab;
-
        items: [string, NotificationItem[]][];
-
        pagination: { cursor: number; more: boolean };
-
      }
-
    >;
-
    config: Config;
-
  };
-
}
-

-
interface LoadedHomeRoute {
-
  resource: "home";
-
  params: {
-
    activeTab?: HomeReposTab;
-
    repoCount: RepoCount;
-
    notificationCount: Map<string, NotificationCount>;
-
    repos: RepoInfo[];
-
    config: Config;
-
  };
-
}
-

-
export type Route = InboxRoute | BootingRoute | HomeRoute | RepoRoute;
-

-
export type LoadedRoute =
-
  | LoadedInboxRoute
-
  | BootingRoute
-
  | LoadedHomeRoute
-
  | LoadedRepoRoute;
+
export type Route = BootingRoute | HomeRoute | RepoRoute;
+
export type LoadedRoute = BootingRoute | LoadedHomeRoute | LoadedRepoRoute;

export async function loadRoute(
  route: Route,
  _previousLoaded: LoadedRoute,
): Promise<LoadedRoute> {
-
  const [count, repoCount, config] = await Promise.all([
-
    invoke<Record<string, NotificationCount>>("count_notifications_by_repo"),
-
    invoke<RepoCount>("repo_count"),
-
    invoke<Config>("config"),
-
  ]);
-
  const notificationCount = new SvelteMap(Object.entries(count));
  if (route.resource === "home") {
-
    let show = "all";
-

-
    if (route.resource === "home") {
-
      if (route.activeTab === "delegate") {
-
        show = "delegate";
-
      } else if (route.activeTab === "contributor") {
-
        show = "contributor";
-
      } else if (route.activeTab === "private") {
-
        show = "private";
-
      }
-
    }
-

-
    const repos = await invoke<RepoInfo[]>("list_repos", { show });
-
    return {
-
      resource: "home",
-
      params: {
-
        activeTab: route.activeTab,
-
        repoCount,
-
        notificationCount,
-
        repos,
-
        config,
-
      },
-
    };
-
  } else if (route.resource === "inbox") {
-
    const notifications: LoadedInboxRoute["params"]["notifications"] =
-
      new SvelteMap();
-
    if (route.activeTab) {
-
      const items = await invoke<
-
        PaginatedQuery<[string, NotificationItem[]][]>
-
      >("list_notifications", {
-
        params: {
-
          repo: route.activeTab.rid,
-
        },
-
      });
-
      notifications.set(route.activeTab.rid, {
-
        repo: route.activeTab,
-
        items: items.content,
-
        pagination: { cursor: items.cursor, more: items.more },
-
      });
-
    } else {
-
      for (const [rid, item] of notificationCount) {
-
        const result = await invoke<
-
          PaginatedQuery<[string, NotificationItem[]][]>
-
        >("list_notifications", {
-
          params: {
-
            repo: rid,
-
          },
-
        });
-
        notifications.set(item.rid, {
-
          repo: { name: item.name, rid: item.rid, count: item.count },
-
          items: result.content,
-
          pagination: { cursor: result.cursor, more: result.more },
-
        });
-
      }
-
    }
-

-
    return {
-
      resource: "inbox",
-
      params: {
-
        activeTab: route.activeTab,
-
        repoCount,
-
        notifications,
-
        notificationCount,
-
        config,
-
      },
-
    };
+
    return loadHome(route);
  } else if (route.resource === "repo.home") {
    return loadRepoHome(route);
  } else if (route.resource === "repo.issue") {
modified src/views/booting/Auth.svelte
@@ -33,7 +33,7 @@
          await createEventEmittersOnce();
        }
        passphrase = " ".repeat(passphrase.length);
-
        await router.push({ resource: "home" });
+
        await router.push({ resource: "home", activeTab: "all" });
      } catch (err) {
        error = err as ErrorWrapper;
      } finally {
modified src/views/home/Inbox.svelte
@@ -1,217 +1,187 @@
<script lang="ts">
-
  import type { Config } from "@bindings/config/Config";
-
  import type { HomeInboxTab } from "@app/lib/router/definitions";
-
  import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
-
  import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
-
  import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
-
  import type { RepoCount } from "@bindings/repo/RepoCount";
-

-
  import * as router from "@app/lib/router";
-
  import { SvelteMap } from "svelte/reactivity";
-
  import { invoke } from "@app/lib/invoke";
-

-
  import Border from "@app/components/Border.svelte";
+
  import type { NotificationsByRepo } from "@bindings/cob/inbox/NotificationsByRepo";
+

  import ConfirmClear from "@app/components/ConfirmClear.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
  import Icon from "@app/components/Icon.svelte";
-
  import Layout from "@app/views/repo/Layout.svelte";
-
  import RepoNotifications from "@app/components/RepoNotifications.svelte";
+
  import NotificationsByRepoComponent from "@app/components/NotificationsByRepo.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";

  interface Props {
-
    activeTab?: HomeInboxTab;
-
    notificationCount: SvelteMap<string, NotificationCount>;
-
    notifications: SvelteMap<
-
      string,
-
      {
-
        repo: HomeInboxTab;
-
        items: [string, NotificationItem[]][];
-
        pagination: { cursor: number; more: boolean };
-
      }
-
    >;
-
    repoCount: RepoCount;
-
    config: Config;
+
    clearAll: () => Promise<void>;
+
    clearByIds: (ids: string[]) => Promise<void>;
+
    clearByRepo: (rid: string) => Promise<void>;
+
    loadNew: () => Promise<void>;
+
    notificationCount: number | undefined;
+
    notificationsByRepo: NotificationsByRepo[];
+
    showAll: (rid: string) => Promise<void>;
  }

-
  /* eslint-disable prefer-const */
-
  let {
+
  const {
+
    clearAll,
+
    clearByIds,
+
    clearByRepo,
+
    loadNew,
    notificationCount,
-
    repoCount,
-
    activeTab,
-
    config,
-
    notifications,
+
    notificationsByRepo,
+
    showAll,
  }: Props = $props();
-
  /* eslint-enable prefer-const */

-
  let cursor: number | undefined = undefined;
-
  let more: boolean | undefined = undefined;
+
  let pinnedRepos: string[] = $state(loadPinnedRepos());
+
  let hiddenRepos: string[] = $state(loadHiddenRepos());

-
  // If we are focused on a repo populate the pagination vars.
-
  $effect(() => {
-
    if (activeTab && notifications.has(activeTab.rid)) {
-
      const n = notifications.get(activeTab.rid);
-
      cursor = n!.pagination.cursor;
-
      more = n!.pagination.more;
-
    }
-
  });
-

-
  async function clearAll() {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "all" },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      notificationCount.clear();
-
      notifications.clear();
+
  function loadPinnedRepos(): string[] {
+
    const storedPinnedRepos = localStorage
+
      ? localStorage.getItem("pinnedInboxRepos")
+
      : null;
+

+
    if (storedPinnedRepos === null) {
+
      return [];
+
    } else {
+
      return JSON.parse(storedPinnedRepos);
    }
  }

-
  async function clearByRepo(rid: string) {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "repo", content: rid },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      await reload([rid]);
-
    }
+
  function updatePinnedRepos(newRepos: string[]) {
+
    pinnedRepos = newRepos;
+
    localStorage.setItem("pinnedInboxRepos", JSON.stringify(newRepos));
  }

-
  async function clearByIds(rid: string, ids: string[]) {
-
    try {
-
      await invoke("clear_notifications", {
-
        params: { type: "ids", content: ids },
-
      });
-
    } catch (error) {
-
      console.error("Clearing notifications failed", error);
-
    } finally {
-
      await reload([rid]);
+
  function togglePin(rid: string) {
+
    const repos = loadPinnedRepos();
+
    if (repos.includes(rid)) {
+
      updatePinnedRepos(repos.filter(r => r !== rid));
+
    } else {
+
      updatePinnedRepos([rid, ...repos]);
    }
  }

-
  async function reload(rids: string[]) {
-
    for (const rid of rids) {
-
      const [n, count] = await Promise.all([
-
        invoke<PaginatedQuery<[string, NotificationItem[]][]>>(
-
          "list_notifications",
-
          {
-
            params: {
-
              repo: rid,
-
            },
-
          },
-
        ),
-
        invoke<Record<string, NotificationCount>>(
-
          "count_notifications_by_repo",
-
        ),
-
      ]);
-
      notificationCount = new SvelteMap(Object.entries(count));
-

-
      notifications.set(rid, {
-
        repo: notificationCount.get(rid)!,
-
        items: n.content,
-
        pagination: { cursor: n.cursor, more: n.more },
-
      });
-

-
      // If we are looking at a single repo and there are no more notifications left after a reload, push the user to the general inbox
-
      if (activeTab && Object.values(n.content).length === 0) {
-
        void router.push({ resource: "inbox" });
-
      }
+
  function loadHiddenRepos(): string[] {
+
    const storedHiddenRepos = localStorage
+
      ? localStorage.getItem("hiddenInboxRepos")
+
      : null;
+

+
    if (storedHiddenRepos === null) {
+
      return [];
+
    } else {
+
      return JSON.parse(storedHiddenRepos);
    }
  }

-
  async function loadMoreContent() {
-
    if (more && activeTab) {
-
      const c = cursor ? cursor : 0;
-
      const p = await invoke<PaginatedQuery<[string, NotificationItem[]][]>>(
-
        "list_notifications",
-
        {
-
          params: {
-
            repo: activeTab.rid,
-
            skip: more ? c + 20 : c,
-
            take: 20,
-
          },
-
        },
-
      );
-

-
      cursor = p.cursor;
-
      more = p.more;
-

-
      const currentNotifications = notifications.get(activeTab.rid);
-
      notifications.set(activeTab.rid, {
-
        repo: currentNotifications!.repo,
-
        items: { ...currentNotifications!.items, ...p.content },
-
        pagination: { cursor: p.cursor, more: p.more },
-
      });
+
  function updateHiddenRepos(newRepos: string[]) {
+
    hiddenRepos = newRepos;
+
    localStorage.setItem("hiddenInboxRepos", JSON.stringify(newRepos));
+
  }
+

+
  function toggleHide(rid: string) {
+
    const repos = loadHiddenRepos();
+
    if (repos.includes(rid)) {
+
      updateHiddenRepos(repos.filter(r => r !== rid));
+
    } else {
+
      updateHiddenRepos([rid, ...repos]);
    }
  }
+

+
  function sortedRepos(
+
    allRepos: NotificationsByRepo[],
+
    pinned: string[],
+
    hidden: string[],
+
  ) {
+
    // Preserve pinning order.
+
    const pinnedRepos = pinned
+
      .map(p => allRepos.find(r => r.rid === p))
+
      .filter((repo): repo is NotificationsByRepo => repo !== undefined);
+

+
    const sortedRepos = allRepos
+
      .filter(r => !pinned.includes(r.rid) && !hidden.includes(r.rid))
+
      .sort((a, b) => a.name.localeCompare(b.name));
+
    const hiddenRepos = allRepos
+
      .filter(r => hidden.includes(r.rid))
+
      .sort((a, b) => a.name.localeCompare(b.name));
+

+
    return [...pinnedRepos, ...sortedRepos, ...hiddenRepos];
+
  }
+

+
  function loadedNotificationCount() {
+
    return notificationsByRepo.reduce((acc, repo) => {
+
      return acc + repo.count;
+
    }, 0);
+
  }
</script>

<style>
  .container {
-
    padding: 1rem 1rem 1rem 0;
+
    width: 100%;
  }
  .header {
    font-weight: var(--font-weight-medium);
    font-size: var(--font-size-medium);
    display: flex;
-
    justify-content: space-between;
-
    padding-right: 1rem;
    align-items: center;
-
    min-height: 2.5rem;
+
    min-height: 2rem;
+
  }
+
  .clear-inbox {
+
    margin-left: auto;
+
    margin-right: 1rem;
+
    display: none;
+
  }
+
  .header:hover .clear-inbox {
+
    display: flex;
+
  }
+
  .repo-list {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
    margin-top: 1rem;
  }
</style>

-
<Layout
-
  {config}
-
  loadMoreContent={async () => {
-
    if (activeTab) {
-
      await loadMoreContent();
-
    }
-
  }}
-
  hideSidebar
-
  styleSecondColumnOverflow="visible">
-
  {#snippet headerCenter()}
-
    <CopyableId id={config.publicKey} />
-
  {/snippet}
-
  {#snippet secondColumn()}
-
    <HomeSidebar
-
      activeTab={{ type: "inbox", repo: activeTab }}
-
      {notificationCount}
-
      {repoCount} />
-
  {/snippet}
-
  <div class="container">
-
    <div class="header">
-
      <div>Inbox</div>
-
      {#if notifications.size > 0}
-
        <ConfirmClear subject="inbox" clear={clearAll} />
+
<div class="container">
+
  <div class="header">
+
    <div>
+
      Inbox
+
      {#if notificationCount !== undefined && notificationCount > 0}
+
        {notificationCount}
      {/if}
    </div>
-
    {#each notifications.values() as { repo, pagination, items }}
-
      <RepoNotifications
-
        all={Boolean(activeTab)}
-
        {clearByIds}
-
        {clearByRepo}
-
        {repo}
-
        more={pagination.more}
-
        {items} />
-
    {:else}
-
      <Border
-
        variant="ghost"
-
        styleAlignItems="center"
-
        styleJustifyContent="center">
-
        <div
-
          class="global-flex"
-
          style:height="4.625rem"
-
          style:justify-content="center">
-
          <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
-
            <Icon name="none" />
-
            No notifications.
-
          </div>
-
        </div>
-
      </Border>
-
    {/each}
+
    {#if notificationCount === undefined || notificationCount === 0}
+
      <div
+
        class="txt-missing txt-small global-flex"
+
        style:gap="0.25rem"
+
        style:margin-left="auto">
+
        <Icon name="thumb-up" />
+
        Yay, inbox zero!
+
      </div>
+
    {/if}
+
    {#if notificationCount !== undefined && notificationCount > loadedNotificationCount()}
+
      <div class="txt-missing txt-small global-flex" style:margin-left="1rem">
+
        <NakedButton variant="ghost" onclick={loadNew}>
+
          See {notificationCount - loadedNotificationCount()} new
+
        </NakedButton>
+
      </div>
+
    {/if}
+
    {#if notificationCount && notificationCount > 0}
+
      <div class="clear-inbox">
+
        <ConfirmClear count={notificationCount} clear={clearAll} />
+
      </div>
+
    {/if}
  </div>
-
</Layout>
+

+
  {#if notificationCount !== undefined && notificationCount > 0}
+
    <div class="repo-list">
+
      {#each sortedRepos(notificationsByRepo, pinnedRepos, hiddenRepos) as repo}
+
        <NotificationsByRepoComponent
+
          count={repo.count}
+
          groupedNotifications={repo.notifications}
+
          hidden={hiddenRepos.includes(repo.rid)}
+
          name={repo.name}
+
          pinned={pinnedRepos.includes(repo.rid)}
+
          rid={repo.rid}
+
          {clearByIds}
+
          {clearByRepo}
+
          {showAll}
+
          {toggleHide}
+
          {togglePin} />
+
      {/each}
+
    </div>
+
  {/if}
+
</div>
modified src/views/home/Repos.svelte
@@ -1,8 +1,7 @@
<script lang="ts">
-
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
-
  import type { HomeReposTab } from "@app/lib/router/definitions";
  import type { Config } from "@bindings/config/Config";
-
  import type { NotificationCount } from "@bindings/cob/inbox/NotificationCount";
+
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";
+
  import type { HomeReposTab } from "@app/views/home/router";
  import type { RepoCount } from "@bindings/repo/RepoCount";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";

@@ -25,15 +24,15 @@
  import { setFocused } from "@app/components/Popover.svelte";

  interface Props {
-
    activeTab?: HomeReposTab;
+
    activeTab: HomeReposTab;
    config: Config;
-
    notificationCount: Map<string, NotificationCount>;
    repoCount: RepoCount;
    repos: RepoInfo[];
+
    notificationCount: number;
  }

  /* eslint-disable prefer-const */
-
  let { config, repos, notificationCount, repoCount, activeTab }: Props =
+
  let { config, repos, repoCount, activeTab, notificationCount }: Props =
    /* eslint-enable prefer-const */
    $props();

@@ -107,15 +106,16 @@
  }
</style>

-
<Layout hideSidebar styleSecondColumnOverflow="visible" {config}>
+
<Layout
+
  {notificationCount}
+
  hideSidebar
+
  styleSecondColumnOverflow="visible"
+
  {config}>
  {#snippet headerCenter()}
    <CopyableId id={config.publicKey} />
  {/snippet}
  {#snippet secondColumn()}
-
    <HomeSidebar
-
      activeTab={{ type: "repos", filter: activeTab }}
-
      {repoCount}
-
      {notificationCount} />
+
    <HomeSidebar {activeTab} {repoCount} />
  {/snippet}
  <div class="container">
    <div class="global-flex" style:margin-bottom="1rem">
added src/views/home/router.ts
@@ -0,0 +1,54 @@
+
import type { Config } from "@bindings/config/Config";
+
import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
import type { RepoCount } from "@bindings/repo/RepoCount";
+

+
import { invoke } from "@app/lib/invoke";
+

+
export type HomeReposTab = "all" | "delegate" | "private" | "contributor";
+

+
export interface HomeRoute {
+
  resource: "home";
+
  activeTab: HomeReposTab;
+
}
+

+
export interface LoadedHomeRoute {
+
  resource: "home";
+
  params: {
+
    activeTab: HomeReposTab;
+
    repoCount: RepoCount;
+
    repos: RepoInfo[];
+
    config: Config;
+
    notificationCount: number;
+
  };
+
}
+

+
export async function loadHome(route: HomeRoute): Promise<LoadedHomeRoute> {
+
  let show = "all";
+

+
  if (route.resource === "home") {
+
    if (route.activeTab === "delegate") {
+
      show = "delegate";
+
    } else if (route.activeTab === "contributor") {
+
      show = "contributor";
+
    } else if (route.activeTab === "private") {
+
      show = "private";
+
    }
+
  }
+

+
  const [config, repoCount, repos, notificationCount] = await Promise.all([
+
    invoke<Config>("config"),
+
    invoke<RepoCount>("repo_count"),
+
    invoke<RepoInfo[]>("list_repos", { show }),
+
    invoke<number>("notification_count"),
+
  ]);
+
  return {
+
    resource: "home",
+
    params: {
+
      activeTab: route.activeTab,
+
      repoCount,
+
      repos,
+
      config,
+
      notificationCount,
+
    },
+
  };
+
}
modified src/views/repo/CreateIssue.svelte
@@ -30,6 +30,7 @@
    issues: Issue[];
    config: Config;
    status: IssueStatus;
+
    notificationCount: number;
  }

  const {
@@ -37,6 +38,7 @@
    issues: initialIssues,
    config,
    status: initialStatus,
+
    notificationCount,
  }: Props = $props();

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
@@ -115,7 +117,7 @@
  }
</style>

-
<Layout {config}>
+
<Layout {notificationCount} {config}>
  {#snippet sidebar()}
    <Sidebar activeTab="issues" rid={repo.rid} />
  {/snippet}
modified src/views/repo/Issue.svelte
@@ -46,6 +46,7 @@
    config: Config;
    threads: Thread[];
    status: IssueStatus;
+
    notificationCount: number;
  }

  /* eslint-disable prefer-const */
@@ -57,6 +58,7 @@
    config,
    threads,
    status: initialStatus,
+
    notificationCount,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -314,7 +316,7 @@
  }
</style>

-
<Layout {config}>
+
<Layout {notificationCount} {config}>
  {#snippet headerCenter()}
    <CopyableId id={issue.id} />
  {/snippet}
modified src/views/repo/Issues.svelte
@@ -24,10 +24,11 @@
    issues: Issue[];
    config: Config;
    status: IssueStatus;
+
    notificationCount: number;
  }

  /* eslint-disable prefer-const */
-
  let { repo, issues, config, status }: Props = $props();
+
  let { notificationCount, repo, issues, config, status }: Props = $props();
  /* eslint-enable prefer-const */

  let searchInput = $state("");
@@ -83,7 +84,11 @@
  }
</style>

-
<Layout hideSidebar styleSecondColumnOverflow="visible" {config}>
+
<Layout
+
  {notificationCount}
+
  hideSidebar
+
  styleSecondColumnOverflow="visible"
+
  {config}>
  {#snippet headerCenter()}
    <CopyableId id={repo.rid} />
  {/snippet}
modified src/views/repo/Layout.svelte
@@ -39,6 +39,7 @@
    sidebar?: Snippet;
    loadMoreContent?: () => Promise<void>;
    loadMoreSecondColumn?: () => Promise<void>;
+
    notificationCount: number;
    hideSidebar?: boolean;
    styleSecondColumnOverflow?: string;
  }
@@ -51,6 +52,7 @@
    sidebar = undefined,
    loadMoreContent = undefined,
    loadMoreSecondColumn = undefined,
+
    notificationCount,
    hideSidebar = false,
    styleSecondColumnOverflow = "scroll",
  }: Props = $props();
@@ -134,7 +136,7 @@

<div class="layout">
  <div class="header">
-
    <Header {config} center={headerCenter}></Header>
+
    <Header {config} center={headerCenter} {notificationCount}></Header>
  </div>

  {#if sidebar}
modified src/views/repo/Patch.svelte
@@ -57,6 +57,7 @@
    activity: Operation<Action>[];
    status: PatchStatus | undefined;
    review: Review | undefined;
+
    notificationCount: number;
  }

  /* eslint-disable prefer-const */
@@ -69,6 +70,7 @@
    status: initialStatus,
    activity,
    review,
+
    notificationCount,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -322,7 +324,7 @@
  }
</style>

-
<Layout {config} loadMoreSecondColumn={loadMoreTeasers}>
+
<Layout {notificationCount} loadMoreSecondColumn={loadMoreTeasers} {config}>
  {#snippet headerCenter()}
    <CopyableId id={patch.id} />
  {/snippet}
modified src/views/repo/Patches.svelte
@@ -26,9 +26,10 @@
    patches: PaginatedQuery<Patch[]>;
    config: Config;
    status: PatchStatus | undefined;
+
    notificationCount: number;
  }

-
  const { repo, patches, config, status }: Props = $props();
+
  const { repo, patches, config, status, notificationCount }: Props = $props();

  let items = $state(patches.content);
  let cursor = patches.cursor;
@@ -118,6 +119,7 @@
</style>

<Layout
+
  {notificationCount}
  {loadMoreContent}
  hideSidebar
  styleSecondColumnOverflow="visible"
modified src/views/repo/RepoHome.svelte
@@ -18,9 +18,10 @@
    config: Config;
    readme: Readme | null;
    repo: RepoInfo;
+
    notificationCount: number;
  }

-
  const { config, readme, repo }: Props = $props();
+
  const { config, readme, repo, notificationCount }: Props = $props();

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
</script>
@@ -37,7 +38,11 @@
  }
</style>

-
<Layout {config} hideSidebar styleSecondColumnOverflow="visible">
+
<Layout
+
  {notificationCount}
+
  {config}
+
  hideSidebar
+
  styleSecondColumnOverflow="visible">
  {#snippet headerCenter()}
    <CopyableId id={repo.rid} />
  {/snippet}
modified src/views/repo/router.ts
@@ -42,6 +42,7 @@ export interface LoadedRepoHomeRoute {
    repo: RepoInfo;
    config: Config;
    readme: Readme | null;
+
    notificationCount: number;
  };
}

@@ -55,6 +56,7 @@ export interface LoadedRepoIssueRoute {
    status: IssueStatus;
    activity: Operation<IssueAction>[];
    threads: Thread[];
+
    notificationCount: number;
  };
}

@@ -65,6 +67,7 @@ export interface LoadedRepoCreateIssueRoute {
    config: Config;
    issues: Issue[];
    status: IssueStatus;
+
    notificationCount: number;
  };
}

@@ -81,6 +84,7 @@ export interface LoadedRepoIssuesRoute {
    config: Config;
    issues: Issue[];
    status: IssueStatus;
+
    notificationCount: number;
  };
}

@@ -105,6 +109,7 @@ export interface LoadedRepoPatchRoute {
    review: Review | undefined;
    revisions: Revision[];
    activity: Operation<PatchAction>[];
+
    notificationCount: number;
  };
}

@@ -121,6 +126,7 @@ export interface LoadedRepoPatchesRoute {
    config: Config;
    patches: PaginatedQuery<Patch[]>;
    status: PatchStatus | undefined;
+
    notificationCount: number;
  };
}

@@ -142,8 +148,9 @@ export type LoadedRepoRoute =
export async function loadPatch(
  route: RepoPatchRoute,
): Promise<LoadedRepoPatchRoute> {
-
  const [config, repo, patches, patch, revisions, activity] = await Promise.all(
-
    [
+
  const [notificationCount, config, repo, patches, patch, revisions, activity] =
+
    await Promise.all([
+
      invoke<number>("notification_count"),
      invoke<Config>("config"),
      invoke<RepoInfo>("repo_by_id", {
        rid: route.rid,
@@ -165,8 +172,7 @@ export async function loadPatch(
        rid: route.rid,
        id: route.patch,
      }),
-
    ],
-
  );
+
    ]);

  const review = revisions
    .flatMap(r => r.reviews || [])
@@ -183,6 +189,7 @@ export async function loadPatch(
      status: route.status,
      review,
      activity,
+
      notificationCount,
    },
  };
}
@@ -190,7 +197,8 @@ export async function loadPatch(
export async function loadPatches(
  route: RepoPatchesRoute,
): Promise<LoadedRepoPatchesRoute> {
-
  const [config, repo, patches] = await Promise.all([
+
  const [notificationCount, config, repo, patches] = await Promise.all([
+
    invoke<number>("notification_count"),
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
      rid: route.rid,
@@ -204,14 +212,15 @@ export async function loadPatches(

  return {
    resource: "repo.patches",
-
    params: { repo, config, patches, status: route.status },
+
    params: { notificationCount, repo, config, patches, status: route.status },
  };
}

export async function loadRepoHome(
  route: RepoHomeRoute,
): Promise<LoadedRepoHomeRoute> {
-
  const [config, repo, readme] = await Promise.all([
+
  const [notificationCount, config, repo, readme] = await Promise.all([
+
    invoke<number>("notification_count"),
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
      rid: route.rid,
@@ -223,14 +232,15 @@ export async function loadRepoHome(

  return {
    resource: "repo.home",
-
    params: { repo, config, readme },
+
    params: { notificationCount, repo, config, readme },
  };
}

export async function loadCreateIssue(
  route: RepoCreateIssueRoute,
): Promise<LoadedRepoCreateIssueRoute> {
-
  const [config, repo, issues] = await Promise.all([
+
  const [notificationCount, config, repo, issues] = await Promise.all([
+
    invoke<number>("notification_count"),
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
      rid: route.rid,
@@ -243,39 +253,42 @@ export async function loadCreateIssue(

  return {
    resource: "repo.createIssue",
-
    params: { repo, config, issues, status: route.status },
+
    params: { notificationCount, repo, config, issues, status: route.status },
  };
}

export async function loadIssue(
  route: RepoIssueRoute,
): Promise<LoadedRepoIssueRoute> {
-
  const [config, repo, issue, activity, issues, threads] = await Promise.all([
-
    invoke<Config>("config"),
-
    invoke<RepoInfo>("repo_by_id", {
-
      rid: route.rid,
-
    }),
-
    invoke<Issue>("issue_by_id", {
-
      rid: route.rid,
-
      id: route.issue,
-
    }),
-
    invoke<Operation<IssueAction>[]>("activity_by_issue", {
-
      rid: route.rid,
-
      id: route.issue,
-
    }),
-
    invoke<Issue[]>("list_issues", {
-
      rid: route.rid,
-
      status: route.status,
-
    }),
-
    invoke<Thread[]>("comment_threads_by_issue_id", {
-
      rid: route.rid,
-
      id: route.issue,
-
    }),
-
  ]);
+
  const [notificationCount, config, repo, issue, activity, issues, threads] =
+
    await Promise.all([
+
      invoke<number>("notification_count"),
+
      invoke<Config>("config"),
+
      invoke<RepoInfo>("repo_by_id", {
+
        rid: route.rid,
+
      }),
+
      invoke<Issue>("issue_by_id", {
+
        rid: route.rid,
+
        id: route.issue,
+
      }),
+
      invoke<Operation<IssueAction>[]>("activity_by_issue", {
+
        rid: route.rid,
+
        id: route.issue,
+
      }),
+
      invoke<Issue[]>("list_issues", {
+
        rid: route.rid,
+
        status: route.status,
+
      }),
+
      invoke<Thread[]>("comment_threads_by_issue_id", {
+
        rid: route.rid,
+
        id: route.issue,
+
      }),
+
    ]);

  return {
    resource: "repo.issue",
    params: {
+
      notificationCount,
      repo,
      config,
      issue,
@@ -290,7 +303,8 @@ export async function loadIssue(
export async function loadIssues(
  route: RepoIssuesRoute,
): Promise<LoadedRepoIssuesRoute> {
-
  const [config, repo, issues] = await Promise.all([
+
  const [notificationCount, config, repo, issues] = await Promise.all([
+
    invoke<number>("notification_count"),
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
      rid: route.rid,
@@ -303,7 +317,7 @@ export async function loadIssues(

  return {
    resource: "repo.issues",
-
    params: { repo, config, issues, status: route.status },
+
    params: { notificationCount, repo, config, issues, status: route.status },
  };
}