Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add global inbox
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
57 files changed +2783 -145 62bdbc37 d5632ed5
added .github/workflows/check-unit-test.yml
@@ -0,0 +1,15 @@
+
name: check-unit-test
+
on: push
+

+
jobs:
+
  check-unit-test:
+
    runs-on: ubuntu-latest
+
    steps:
+
      - name: Setup Node
+
        uses: actions/setup-node@v4
+
        with:
+
          node-version: "20.9.0"
+
      - name: Checkout
+
        uses: actions/checkout@v4
+
      - run: npm ci
+
      - run: npm run test:unit
modified Cargo.lock
@@ -4144,6 +4144,7 @@ dependencies = [
 "radicle-surf",
 "serde",
 "serde_json",
+
 "sqlite",
 "tempfile",
 "thiserror 1.0.69",
 "tree-sitter-bash",
modified crates/radicle-tauri/src/commands.rs
@@ -1,6 +1,7 @@
pub mod auth;
pub mod cob;
pub mod diff;
+
pub mod inbox;
pub mod profile;
pub mod repo;
pub mod thread;
added crates/radicle-tauri/src/commands/inbox.rs
@@ -0,0 +1,181 @@
+
use std::collections::BTreeMap;
+

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

+
use radicle_types::cobs::PaginatedQuery;
+
use radicle_types::domain::inbox::models::notification;
+
use radicle_types::domain::inbox::service::Service;
+
use radicle_types::domain::inbox::traits::InboxService;
+
use radicle_types::error::Error;
+
use radicle_types::outbound::sqlite::Sqlite;
+
use radicle_types::AppState;
+

+
#[tauri::command]
+
pub fn list_notifications(
+
    ctx: tauri::State<AppState>,
+
    sqlite_service: tauri::State<Service<Sqlite>>,
+
    params: notification::RepoGroupParams,
+
) -> Result<
+
    PaginatedQuery<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>,
+
    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
+
                                    }
+
                                    Err(e) => {
+
                                        log::error!("{}", e);
+
                                        None
+
                                    }
+
                                }
+
                            } else {
+
                                None
+
                            }
+
                        }
+
                        _ => None,
+
                    }
+
                })
+
                .collect::<Vec<_>>();
+

+
            (qualified, items)
+
        })
+
        .filter(|(_, v)| !v.is_empty())
+
        .collect::<BTreeMap<git::Qualified<'static>, Vec<notification::NotificationItem>>>();
+

+
    Ok(PaginatedQuery {
+
        cursor,
+
        more,
+
        content,
+
    })
+
}
+

+
#[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 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>>();
+

+
    Ok(result)
+
}
+

+
#[tauri::command]
+
pub fn clear_notifications(
+
    ctx: tauri::State<AppState>,
+
    params: notification::SetStatusNotifications,
+
) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+
    let mut notifications = profile.notifications_mut()?;
+
    match params {
+
        notification::SetStatusNotifications::Ids(ids) => notifications.clear(&ids)?,
+
        notification::SetStatusNotifications::Repo(repo) => notifications.clear_by_repo(&repo)?,
+
        notification::SetStatusNotifications::All => notifications.clear_all()?,
+
    };
+

+
    Ok(())
+
}
modified crates/radicle-tauri/src/commands/repo.rs
@@ -16,6 +16,11 @@ pub fn list_repos(
}

#[tauri::command]
+
pub fn repo_count(ctx: tauri::State<AppState>) -> Result<types::repo::RepoCount, Error> {
+
    ctx.repo_count()
+
}
+

+
#[tauri::command]
pub fn repo_by_id(
    ctx: tauri::State<AppState>,
    rid: RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -1,14 +1,15 @@
mod commands;

-
use tauri::Emitter;
-
use tauri::Manager;
+
use tauri::{Emitter, Manager};

-
use radicle::node::Handle;
+
use radicle::node::{Handle, NOTIFICATIONS_DB_FILE};
use radicle::Node;
+

+
use radicle_types::domain;
use radicle_types::error::Error;
use radicle_types::AppState;

-
use commands::{auth, cob, diff, profile, repo, thread};
+
use commands::{auth, cob, diff, inbox, profile, repo, thread};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -33,12 +34,18 @@ pub fn run() {
                }),
            }?;

+
            let inbox_db = radicle_types::outbound::sqlite::Sqlite::reader(
+
                profile.node().join(NOTIFICATIONS_DB_FILE),
+
            )?;
+
            let inbox_service = domain::inbox::service::Service::new(inbox_db);
+

            let events_handler = app.handle().clone();
            let node_handler = app.handle().clone();

            let node = Node::new(profile.socket());
            let node_status = node.clone();

+
            app.manage(inbox_service);
            app.manage(AppState { profile });

            tauri::async_runtime::spawn(async move {
@@ -71,11 +78,15 @@ pub fn run() {
        .plugin(tauri_plugin_window_state::Builder::default().build())
        .invoke_handler(tauri::generate_handler![
            auth::authenticate,
+
            repo::repo_count,
            repo::list_repos,
            repo::repo_by_id,
            repo::diff_stats,
            repo::list_commits,
            diff::get_diff,
+
            inbox::list_notifications,
+
            inbox::count_notifications_by_repo,
+
            inbox::clear_notifications,
            cob::get_embed,
            cob::save_embed_to_disk,
            cob::save_embed_by_path,
modified crates/radicle-types/Cargo.toml
@@ -15,6 +15,7 @@ radicle = { version = "0.14.0", features = ["test"] }
radicle-surf = { version = "0.22.1", features = ["serde"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = { version = "1.0.132" }
+
sqlite = { version = "0.32.0", features = ["bundled"] }
tempfile = { version = "3.14.0" }
thiserror = { version = "1.0.65" }
tree-sitter-bash = { version = "0.23.3" }
added crates/radicle-types/bindings/cob/inbox/ActionWithAuthor.ts
@@ -0,0 +1,8 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Author } from "../Author";
+

+
export type ActionWithAuthor<T> = {
+
  oid: string;
+
  timestamp: number;
+
  author: Author;
+
} & T;
added crates/radicle-types/bindings/cob/inbox/Issue.ts
@@ -0,0 +1,15 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Action } from "../issue/Action";
+
import type { ActionWithAuthor } from "./ActionWithAuthor";
+
import type { RefUpdate } from "./RefUpdate";
+
import type { State } from "../issue/State";
+

+
export type Issue = {
+
  rowId: string;
+
  id: string;
+
  update: RefUpdate;
+
  title: string;
+
  timestamp: number;
+
  status: State;
+
  actions: Array<ActionWithAuthor<Action>>;
+
};
added crates/radicle-types/bindings/cob/inbox/NotificationCount.ts
@@ -0,0 +1,3 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type NotificationCount = { rid: string; name: string; count: number };
added crates/radicle-types/bindings/cob/inbox/NotificationItem.ts
@@ -0,0 +1,7 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Issue } from "./Issue";
+
import type { Patch } from "./Patch";
+

+
export type NotificationItem =
+
  | { "type": "issue" } & Issue
+
  | { "type": "patch" } & Patch;
added crates/radicle-types/bindings/cob/inbox/Patch.ts
@@ -0,0 +1,15 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Action } from "../patch/Action";
+
import type { ActionWithAuthor } from "./ActionWithAuthor";
+
import type { RefUpdate } from "./RefUpdate";
+
import type { State } from "../patch/State";
+

+
export type Patch = {
+
  rowId: string;
+
  id: string;
+
  update: RefUpdate;
+
  timestamp: number;
+
  title: string;
+
  status: State;
+
  actions: Array<ActionWithAuthor<Action>>;
+
};
added crates/radicle-types/bindings/cob/inbox/RefUpdate.ts
@@ -0,0 +1,7 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type RefUpdate =
+
  | { "type": "updated"; name: string; old: string; new: string }
+
  | { "type": "created"; name: string; oid: string }
+
  | { "type": "deleted"; name: string; oid: string }
+
  | { "type": "skipped"; name: string; oid: string };
added crates/radicle-types/bindings/cob/inbox/SetStatusNotifications.ts
@@ -0,0 +1,6 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type SetStatusNotifications =
+
  | { "type": "ids"; "content": Array<number> }
+
  | { "type": "repo"; "content": string }
+
  | { "type": "all" };
added crates/radicle-types/bindings/cob/inbox/TypedId.ts
@@ -0,0 +1,6 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
/**
+
 * The exact identifier for a particular COB.
+
 */
+
export type TypedId = { id: string; typeName: string };
added crates/radicle-types/bindings/repo/RepoCount.ts
@@ -0,0 +1,8 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type RepoCount = {
+
  total: number;
+
  delegate: number;
+
  private: number;
+
  seeding: number;
+
};
modified crates/radicle-types/src/cobs.rs
@@ -11,7 +11,7 @@ pub mod patch;
pub mod stream;
pub mod thread;

-
#[derive(Debug, Serialize, TS)]
+
#[derive(Debug, Clone, Serialize, TS, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/")]
modified crates/radicle-types/src/cobs/issue.rs
@@ -54,7 +54,7 @@ impl Issue {
    }
}

-
#[derive(Default, Serialize, Deserialize, TS)]
+
#[derive(Debug, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "status")]
#[ts(export)]
#[ts(export_to = "cob/issue/")]
@@ -88,7 +88,7 @@ impl From<issue::State> for State {
    }
}

-
#[derive(Serialize, Deserialize, TS)]
+
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/issue/")]
@@ -130,7 +130,7 @@ pub struct NewIssue {
    pub embeds: Vec<cobs::thread::Embed>,
}

-
#[derive(Serialize, Deserialize, TS)]
+
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/issue/")]
modified crates/radicle-types/src/cobs/patch.rs
@@ -294,7 +294,7 @@ impl From<Verdict> for cob::patch::Verdict {
    }
}

-
#[derive(Serialize, Deserialize, TS)]
+
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/patch/")]
modified crates/radicle-types/src/cobs/stream.rs
@@ -1,16 +1,19 @@
-
mod error;
+
pub mod error;
mod iter;

pub use iter::ActionsIter;
use iter::Walk;

+
use std::fmt::Debug;
use std::marker::PhantomData;

-
use serde::Deserialize;
-

use radicle::cob::{ObjectId, TypeName};
-
use radicle::git::raw as git2;
use radicle::git::Oid;
+
use radicle::profile::Aliases;
+
use radicle::storage::git::Repository;
+
use serde::Deserialize;
+

+
use crate::domain::inbox::models::notification::ActionWithAuthor;

/// Helper trait for anything can provide its initial commit. Generally, this is
/// the root of a COB object.
@@ -92,7 +95,8 @@ impl HasRoot for CobRange {
///
/// To construct a `Stream`, use [`Stream::new`].
pub struct Stream<'a, A> {
-
    repo: &'a git2::Repository,
+
    repo: &'a Repository,
+
    aliases: &'a Aliases,
    range: CobRange,
    typename: TypeName,
    marker: PhantomData<A>,
@@ -101,11 +105,17 @@ pub struct Stream<'a, A> {
impl<'a, A> Stream<'a, A> {
    /// Construct a new stream providing the underlying `repo`, a [`CobRange`],
    /// and the [`TypeName`] of the COB that is being streamed.
-
    pub fn new(repo: &'a git2::Repository, range: CobRange, typename: TypeName) -> Self {
+
    pub fn new(
+
        repo: &'a Repository,
+
        range: CobRange,
+
        typename: TypeName,
+
        aliases: &'a Aliases,
+
    ) -> Self {
        Self {
            repo,
            range,
            typename,
+
            aliases,
            marker: PhantomData,
        }
    }
@@ -120,9 +130,10 @@ impl<'a, A> HasRoot for Stream<'a, A> {
impl<'a, A> CobStream for Stream<'a, A>
where
    A: for<'de> Deserialize<'de>,
+
    A: Debug,
{
    type IterError = error::Actions;
-
    type Action = A;
+
    type Action = ActionWithAuthor<A>;
    type Iter = ActionsIter<'a, A>;

    fn all(&self) -> Result<Self::Iter, error::Stream> {
@@ -131,6 +142,8 @@ where
                .iter(self.repo)
                .map_err(error::Stream::new)?,
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
        ))
    }

@@ -141,6 +154,8 @@ where
                .iter(self.repo)
                .map_err(error::Stream::new)?,
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
        ))
    }

@@ -151,6 +166,8 @@ where
                .iter(self.repo)
                .map_err(error::Stream::new)?,
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
        ))
    }

@@ -160,6 +177,8 @@ where
                .iter(self.repo)
                .map_err(error::Stream::new)?,
            self.typename.clone(),
+
            self.repo,
+
            self.aliases,
        ))
    }
}
modified crates/radicle-types/src/cobs/stream/iter.rs
@@ -1,12 +1,19 @@
+
use std::fmt::Debug;
use std::marker::PhantomData;
use std::path::Path;

use serde::Deserialize;
use serde_json as json;

-
use radicle::cob::{Manifest, TypeName};
+
use radicle::cob::change::Storage;
+
use radicle::cob::{Manifest, Op, TypeName};
use radicle::git::raw as git2;
use radicle::git::{Oid, PatternString};
+
use radicle::profile::Aliases;
+
use radicle::storage::git::Repository;
+

+
use crate::cobs::Author;
+
use crate::domain::inbox::models::notification::ActionWithAuthor;

use super::error;
use super::CobRange;
@@ -42,7 +49,7 @@ impl From<PatternString> for Until {
/// from.
pub(super) struct WalkIter<'a> {
    /// Git repository for looking up the commit object during the revwalk.
-
    repo: &'a git2::Repository,
+
    repo: &'a Repository,
    /// The root commit that is being walked from.
    ///
    /// N.b. This is required since ranges are non-inclusive in Git, and if the
@@ -79,8 +86,8 @@ impl Walk {
    }

    /// Get the iterator for the walk.
-
    pub(super) fn iter(self, repo: &git2::Repository) -> Result<WalkIter<'_>, git2::Error> {
-
        let mut walk = repo.revwalk()?;
+
    pub(super) fn iter(self, repo: &Repository) -> Result<WalkIter<'_>, git2::Error> {
+
        let mut walk = repo.backend.revwalk()?;
        // N.b. ensure that we start from the `self.from` commit.
        walk.set_sorting(git2::Sort::TOPOLOGICAL.union(git2::Sort::REVERSE))?;
        match self.until {
@@ -106,10 +113,10 @@ impl<'a> Iterator for WalkIter<'a> {
        // N.b. ensure that we start using the `from` commit and use the revwalk
        // after that.
        if let Some(from) = self.from.take() {
-
            return Some(self.repo.find_commit(*from));
+
            return Some(self.repo.backend.find_commit(*from));
        }
        let oid = self.inner.next()?;
-
        Some(oid.and_then(|oid| self.repo.find_commit(oid)))
+
        Some(oid.and_then(|oid| self.repo.backend.find_commit(oid)))
    }
}

@@ -124,14 +131,23 @@ pub struct ActionsIter<'a, A> {
    /// The walk can iterate over other COBs, e.g. an Identity COB, so this is
    /// used to filter for the correct type.
    typename: TypeName,
+
    repo: &'a Repository,
+
    aliases: &'a Aliases,
}

impl<'a, A> ActionsIter<'a, A> {
-
    pub(super) fn new(walk: WalkIter<'a>, typename: TypeName) -> Self {
+
    pub(super) fn new(
+
        walk: WalkIter<'a>,
+
        typename: TypeName,
+
        repo: &'a Repository,
+
        aliases: &'a Aliases,
+
    ) -> Self {
        Self {
            walk,
            tree: None,
            typename,
+
            repo,
+
            aliases,
        }
    }

@@ -147,7 +163,7 @@ impl<'a, A> ActionsIter<'a, A> {
            }
        };
        let object = entry
-
            .to_object(self.walk.repo)
+
            .to_object(&self.walk.repo.backend)
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
        let blob = object
            .into_blob()
@@ -169,8 +185,9 @@ impl<'a, A> ActionsIter<'a, A> {
impl<'a, A> Iterator for ActionsIter<'a, A>
where
    A: for<'de> Deserialize<'de>,
+
    A: Debug,
{
-
    type Item = Result<A, error::Actions>;
+
    type Item = Result<ActionWithAuthor<A>, error::Actions>;

    fn next(&mut self) -> Option<Self::Item> {
        // Are we currently iterating over a tree?
@@ -202,8 +219,13 @@ where
                            }
                            log::trace!(target: "radicle", "Iterating over commit {}", commit.id());
                            log::trace!(target: "radicle", "Iterating over tree {}", tree.id());
+

+
                            let entry = self.repo.load(commit.id().into()).ok()?;
+
                            let op = Op::from(entry);
+
                            let author = Author::new(&op.author.into(), self.aliases);
                            // Set the tree iterator and walk over that
-
                            self.tree = Some(TreeActionsIter::new(self.walk.repo, tree));
+
                            self.tree =
+
                                Some(TreeActionsIter::new(self.walk.repo, tree, op, author));
                            // Hide this commit so we do not double process it
                            self.walk.inner.hide(commit.id()).ok();
                            self.next()
@@ -227,9 +249,11 @@ where
struct TreeActionsIter<'a, A> {
    /// The repository is required to get the underlying object of the tree
    /// entry.
-
    repo: &'a git2::Repository,
+
    repo: &'a Repository,
    /// The Git tree from which the actions are being extracted.
    tree: git2::Tree<'a>,
+
    op: Op<Vec<u8>>,
+
    author: Author,
    /// Use an index to keep track of which entry is being processed. Note that
    /// `TreeIter` is *not* used since it poses many borrow-checker challenge.
    /// Instead, `self.tree.iter()` is called and the iterator is indexed into.
@@ -239,13 +263,15 @@ struct TreeActionsIter<'a, A> {
}

impl<'a, A> TreeActionsIter<'a, A> {
-
    fn new(repo: &'a git2::Repository, tree: git2::Tree<'a>) -> Self
+
    fn new(repo: &'a Repository, tree: git2::Tree<'a>, op: Op<Vec<u8>>, author: Author) -> Self
    where
        A: for<'de> Deserialize<'de>,
    {
        Self {
            repo,
            tree,
+
            op,
+
            author,
            index: 0,
            marker: PhantomData,
        }
@@ -256,14 +282,15 @@ impl<'a, A> Iterator for TreeActionsIter<'a, A>
where
    A: for<'de> Deserialize<'de>,
{
-
    type Item = Result<A, error::TreeAction>;
+
    type Item = Result<ActionWithAuthor<A>, error::TreeAction>;

    fn next(&mut self) -> Option<Self::Item> {
        let entry = self.tree.iter().nth(self.index)?;
        self.index += 1;
        // N.b. if `from_tree_entry` is `None` we have filtered the entry so we
        // go the `next` entry
-
        from_tree_entry(self.repo, entry).or_else(|| self.next())
+
        from_tree_entry(self.repo, entry, self.op.clone(), self.author.clone())
+
            .or_else(|| self.next())
    }
}

@@ -272,15 +299,17 @@ where
///
/// The entry is only an action if it is a blob and its name is numerical.
fn from_tree_entry<A>(
-
    repo: &git2::Repository,
+
    repo: &Repository,
    entry: git2::TreeEntry,
-
) -> Option<Result<A, error::TreeAction>>
+
    op: Op<Vec<u8>>,
+
    author: Author,
+
) -> Option<Result<ActionWithAuthor<A>, error::TreeAction>>
where
    A: for<'de> Deserialize<'de>,
{
-
    let as_action = |entry: git2::TreeEntry| -> Result<A, error::TreeAction> {
+
    let as_action = |entry: git2::TreeEntry| -> Result<ActionWithAuthor<A>, error::TreeAction> {
        let object = entry
-
            .to_object(repo)
+
            .to_object(&repo.backend)
            .map_err(|err| error::TreeAction::InvalidEntry { err })?;
        let blob = object
            .into_blob()
@@ -289,7 +318,7 @@ where
                    .kind()
                    .map_or("unknown".to_string(), |kind| kind.to_string()),
            })?;
-
        action(&blob).map_err(error::TreeAction::from)
+
        action(&blob, op, author).map_err(error::TreeAction::from)
    };
    let name = entry.name()?;
    // An entry is only considered an action if it:
@@ -301,10 +330,22 @@ where
}

/// Helper to deserialize an action from a blob's contents.
-
fn action<A>(blob: &git2::Blob) -> Result<A, error::Action>
+
fn action<A>(
+
    blob: &git2::Blob,
+
    op: Op<Vec<u8>>,
+
    author: Author,
+
) -> Result<ActionWithAuthor<A>, error::Action>
where
    A: for<'de> Deserialize<'de>,
{
    log::trace!(target: "radicle", "Deserializing action {}", blob.id());
-
    json::from_slice::<A>(blob.content()).map_err(|err| error::Action::new(blob.id().into(), err))
+
    let action = json::from_slice::<A>(blob.content())
+
        .map_err(|err| error::Action::new(blob.id().into(), err))?;
+

+
    Ok(ActionWithAuthor {
+
        author,
+
        timestamp: op.timestamp,
+
        oid: op.id,
+
        action,
+
    })
}
modified crates/radicle-types/src/cobs/thread.rs
@@ -194,7 +194,7 @@ pub struct NewPatchComment {
    pub embeds: Vec<Embed>,
}

-
#[derive(Clone, TS, Serialize, Deserialize)]
+
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/thread/")]
@@ -239,7 +239,7 @@ impl From<CodeLocation> for cob::CodeLocation {
    }
}

-
#[derive(Clone, TS, Serialize, Deserialize)]
+
#[derive(Debug, Clone, TS, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export)]
#[ts(export_to = "cob/thread/")]
@@ -273,7 +273,7 @@ impl From<CodeRange> for cob::CodeRange {
    }
}

-
#[derive(TS, Clone, Deserialize, Serialize)]
+
#[derive(Debug, TS, Clone, Deserialize, Serialize)]
#[ts(export)]
#[ts(export_to = "cob/thread/")]
pub struct Embed {
added crates/radicle-types/src/domain.rs
@@ -0,0 +1 @@
+
pub mod inbox;
added crates/radicle-types/src/domain/inbox.rs
@@ -0,0 +1,3 @@
+
pub mod models;
+
pub mod service;
+
pub mod traits;
added crates/radicle-types/src/domain/inbox/models.rs
@@ -0,0 +1 @@
+
pub mod notification;
added crates/radicle-types/src/domain/inbox/models/notification.rs
@@ -0,0 +1,316 @@
+
use std::fmt::Debug;
+

+
use radicle::cob::Timestamp;
+
use radicle::node::notifications::NotificationId;
+
use radicle::profile::Aliases;
+
use radicle::{cob, git, identity, node, storage};
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use crate::cobs::stream::{self, CobStream};
+
use crate::cobs::{self, Author};
+

+
#[derive(Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[serde(tag = "type", content = "content")]
+
#[ts(export)]
+
#[ts(export_to = "cob/inbox/")]
+
pub enum SetStatusNotifications {
+
    Ids(Vec<NotificationId>),
+
    Repo(#[ts(as = "String")] identity::RepoId),
+
    All,
+
}
+

+
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)]
+
#[serde(rename = "camelCase")]
+
pub struct NotificationRow {
+
    pub row_id: node::notifications::NotificationId,
+
    pub timestamp: localtime::LocalTime,
+
    /// Node Id that provided us with this notification.
+
    pub remote: storage::RemoteId,
+
    pub old: Option<git::Oid>,
+
    pub new: Option<git::Oid>,
+
}
+

+
pub type RepoGroup = std::collections::BTreeMap<git::Qualified<'static>, Vec<NotificationRow>>;
+
pub type CountByRepo = (identity::RepoId, usize);
+

+
#[derive(Clone, Debug)]
+
pub struct CountsByRepoParams {
+
    pub repo: identity::RepoId,
+
}
+

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

+
#[derive(Debug, thiserror::Error)]
+
pub enum ListNotificationsError {
+
    #[error(transparent)]
+
    RefError(#[from] git::RefError),
+

+
    #[error(transparent)]
+
    SerdeJSON(#[from] serde_json::Error),
+

+
    #[error(transparent)]
+
    Sqlite(#[from] sqlite::Error),
+

+
    #[error(transparent)]
+
    CobStream(#[from] stream::error::Stream),
+

+
    #[error(transparent)]
+
    NotificationKindError(#[from] node::notifications::NotificationKindError),
+

+
    #[error(transparent)]
+
    Unknown(#[from] anyhow::Error),
+
    // to be extended as new error scenarios are introduced
+
}
+

+
pub fn actions<A>(
+
    typename: cob::TypeName,
+
    oid: cob::ObjectId,
+
    from: Option<git::Oid>,
+
    until: Option<git::Oid>,
+
    repo: &storage::git::Repository,
+
    aliases: &Aliases,
+
) -> Result<Vec<ActionWithAuthor<A>>, ListNotificationsError>
+
where
+
    A: serde::Serialize,
+
    A: for<'de> serde::Deserialize<'de>,
+
    A: Debug,
+
{
+
    let history = stream::CobRange::new(&typename, &oid);
+
    let stream = stream::Stream::<A>::new(repo, history, typename, aliases);
+
    let iter = match (from, until) {
+
        (None, None) => stream.all()?,
+
        (None, Some(until)) => stream.until(until)?,
+
        (Some(from), None) => stream.since(from)?,
+
        (Some(from), Some(until)) => stream.range(from, until)?,
+
    };
+

+
    Ok(iter.filter_map(|a| a.ok()).collect::<Vec<_>>())
+
}
+

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

+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[serde(tag = "type")]
+
#[ts(export)]
+
#[ts(export_to = "cob/inbox/")]
+
pub enum NotificationItem {
+
    Issue(Issue),
+
    Patch(Patch),
+
}
+

+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export_to = "cob/inbox/")]
+
pub struct Issue {
+
    #[ts(as = "String")]
+
    pub row_id: NotificationId,
+
    #[ts(as = "String")]
+
    pub id: cob::ObjectId,
+
    pub update: RefUpdate,
+
    pub title: String,
+
    #[ts(type = "number")]
+
    pub timestamp: localtime::LocalTime,
+
    pub status: cobs::issue::State,
+
    pub actions: Vec<ActionWithAuthor<cobs::issue::Action>>,
+
}
+

+
#[derive(Debug, Serialize, TS, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export_to = "cob/inbox/")]
+
pub struct ActionWithAuthor<T> {
+
    #[ts(as = "String")]
+
    pub oid: git::Oid,
+
    #[ts(type = "number")]
+
    pub timestamp: Timestamp,
+
    pub author: Author,
+
    #[serde(flatten)]
+
    pub action: T,
+
}
+

+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export_to = "cob/inbox/")]
+
pub struct Patch {
+
    #[ts(as = "String")]
+
    pub row_id: NotificationId,
+
    #[ts(as = "String")]
+
    pub id: cob::ObjectId,
+
    pub update: RefUpdate,
+
    #[ts(type = "number")]
+
    pub timestamp: localtime::LocalTime,
+
    pub title: String,
+
    pub status: cobs::patch::State,
+
    pub actions: Vec<ActionWithAuthor<cobs::patch::Action>>,
+
}
+

+
/// Type of notification.
+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[serde(tag = "type")]
+
pub enum NotificationKind {
+
    /// A COB changed.
+
    Cob {
+
        #[serde(flatten)]
+
        typed_id: TypedId,
+
    },
+
    /// A source branch changed.
+
    Branch {
+
        #[ts(as = "String")]
+
        name: git::BranchName,
+
    },
+
    /// Unknown reference.
+
    Unknown {
+
        #[ts(as = "String")]
+
        refname: git::Qualified<'static>,
+
    },
+
}
+

+
impl From<node::notifications::NotificationKind> for NotificationKind {
+
    fn from(value: node::notifications::NotificationKind) -> Self {
+
        match value {
+
            node::notifications::NotificationKind::Branch { name } => Self::Branch { name },
+
            node::notifications::NotificationKind::Cob { typed_id } => Self::Cob {
+
                typed_id: typed_id.into(),
+
            },
+
            node::notifications::NotificationKind::Unknown { refname } => Self::Unknown { refname },
+
        }
+
    }
+
}
+

+
impl From<NotificationKind> for node::notifications::NotificationKind {
+
    fn from(value: NotificationKind) -> Self {
+
        match value {
+
            NotificationKind::Branch { name } => {
+
                node::notifications::NotificationKind::Branch { name }
+
            }
+
            NotificationKind::Cob { typed_id } => node::notifications::NotificationKind::Cob {
+
                typed_id: typed_id.into(),
+
            },
+
            NotificationKind::Unknown { refname } => {
+
                node::notifications::NotificationKind::Unknown { refname }
+
            }
+
        }
+
    }
+
}
+

+
/// The exact identifier for a particular COB.
+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/inbox/")]
+
pub struct TypedId {
+
    #[ts(as = "String")]
+
    pub id: cob::ObjectId,
+
    #[ts(as = "String")]
+
    pub type_name: cob::TypeName,
+
}
+

+
impl From<cob::TypedId> for TypedId {
+
    fn from(value: cob::TypedId) -> Self {
+
        Self {
+
            id: value.id,
+
            type_name: value.type_name,
+
        }
+
    }
+
}
+

+
impl From<TypedId> for cob::TypedId {
+
    fn from(value: TypedId) -> Self {
+
        Self {
+
            id: value.id,
+
            type_name: value.type_name,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[serde(tag = "type")]
+
#[ts(export)]
+
#[ts(export_to = "cob/inbox/")]
+
pub enum RefUpdate {
+
    Updated {
+
        #[ts(as = "String")]
+
        name: git::RefString,
+
        #[ts(as = "String")]
+
        old: git::Oid,
+
        #[ts(as = "String")]
+
        new: git::Oid,
+
    },
+
    Created {
+
        #[ts(as = "String")]
+
        name: git::RefString,
+
        #[ts(as = "String")]
+
        oid: git::Oid,
+
    },
+
    Deleted {
+
        #[ts(as = "String")]
+
        name: git::RefString,
+
        #[ts(as = "String")]
+
        oid: git::Oid,
+
    },
+
    Skipped {
+
        #[ts(as = "String")]
+
        name: git::RefString,
+
        #[ts(as = "String")]
+
        oid: git::Oid,
+
    },
+
}
+

+
impl From<storage::RefUpdate> for RefUpdate {
+
    fn from(value: storage::RefUpdate) -> Self {
+
        match value {
+
            storage::RefUpdate::Updated { name, old, new } => RefUpdate::Updated { name, old, new },
+
            storage::RefUpdate::Created { name, oid } => RefUpdate::Created { name, oid },
+
            storage::RefUpdate::Deleted { name, oid } => RefUpdate::Deleted { name, oid },
+
            storage::RefUpdate::Skipped { name, oid } => RefUpdate::Skipped { name, oid },
+
        }
+
    }
+
}
+

+
impl From<RefUpdate> for storage::RefUpdate {
+
    fn from(value: RefUpdate) -> Self {
+
        match value {
+
            RefUpdate::Updated { name, old, new } => storage::RefUpdate::Updated { name, old, new },
+
            RefUpdate::Created { name, oid } => storage::RefUpdate::Created { name, oid },
+
            RefUpdate::Deleted { name, oid } => storage::RefUpdate::Deleted { name, oid },
+
            RefUpdate::Skipped { name, oid } => storage::RefUpdate::Skipped { name, oid },
+
        }
+
    }
+
}
+

+
impl From<(git::RefString, Option<git::Oid>, Option<git::Oid>)> for RefUpdate {
+
    fn from((name, new, old): (git::RefString, Option<git::Oid>, Option<git::Oid>)) -> Self {
+
        match (new, old) {
+
            (None, Some(b)) => RefUpdate::Deleted { name, oid: b },
+
            (Some(a), None) => RefUpdate::Created { name, oid: a },
+
            (Some(a), Some(b)) if a != b => RefUpdate::Updated {
+
                name,
+
                old: a,
+
                new: b,
+
            },
+
            _ => RefUpdate::Skipped {
+
                name,
+
                oid: new.unwrap_or(radicle::git::raw::Oid::zero().into()),
+
            },
+
        }
+
    }
+
}
added crates/radicle-types/src/domain/inbox/service.rs
@@ -0,0 +1,47 @@
+
use radicle::git;
+

+
use crate::domain::inbox::models::notification::{
+
    CountByRepo, ListNotificationsError, NotificationRow, RepoGroupParams,
+
};
+
use crate::domain::inbox::traits::{InboxService, InboxStorage};
+

+
#[derive(Debug, Clone)]
+
pub struct Service<I>
+
where
+
    I: InboxStorage,
+
{
+
    inbox: I,
+
}
+

+
impl<I> Service<I>
+
where
+
    I: InboxStorage,
+
{
+
    pub fn new(inbox: I) -> Self {
+
        Self { inbox }
+
    }
+
}
+

+
impl<I> InboxService for Service<I>
+
where
+
    I: InboxStorage,
+
{
+
    fn counts_by_repo(
+
        &self,
+
    ) -> Result<
+
        impl Iterator<Item = Result<CountByRepo, ListNotificationsError>>,
+
        ListNotificationsError,
+
    > {
+
        self.inbox.counts_by_repo()
+
    }
+

+
    fn repo_group(
+
        &self,
+
        params: RepoGroupParams,
+
    ) -> Result<
+
        std::collections::BTreeMap<git::Qualified<'static>, Vec<NotificationRow>>,
+
        ListNotificationsError,
+
    > {
+
        self.inbox.repo_group(params)
+
    }
+
}
added crates/radicle-types/src/domain/inbox/traits.rs
@@ -0,0 +1,28 @@
+
use crate::domain::inbox::models::notification::{
+
    CountByRepo, ListNotificationsError, RepoGroupParams,
+
};
+

+
use super::models::notification::RepoGroup;
+

+
pub trait InboxStorage {
+
    fn counts_by_repo(
+
        &self,
+
    ) -> Result<
+
        impl Iterator<Item = Result<CountByRepo, ListNotificationsError>>,
+
        ListNotificationsError,
+
    >;
+

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

+
pub trait InboxService {
+
    /// Get the total notification count by repos.
+
    fn counts_by_repo(
+
        &self,
+
    ) -> Result<
+
        impl Iterator<Item = Result<CountByRepo, ListNotificationsError>>,
+
        ListNotificationsError,
+
    >;
+

+
    fn repo_group(&self, params: RepoGroupParams) -> Result<RepoGroup, ListNotificationsError>;
+
}
modified crates/radicle-types/src/error.rs
@@ -3,12 +3,20 @@ use axum::http::{Response, StatusCode};
use axum::response::IntoResponse;
use serde::Serialize;

+
use crate::cobs::stream;
+

#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Profile error.
    #[error(transparent)]
    Profile(#[from] radicle::profile::Error),

+
    /// List notification error.
+
    #[error(transparent)]
+
    ListNotificationsError(
+
        #[from] crate::domain::inbox::models::notification::ListNotificationsError,
+
    ),
+

    /// CobStore error.
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),
@@ -21,6 +29,22 @@ pub enum Error {
    #[error(transparent)]
    Io(#[from] std::io::Error),

+
    /// Sqlite error.
+
    #[error(transparent)]
+
    Sqlite(#[from] sqlite::Error),
+

+
    /// Io error.
+
    #[error(transparent)]
+
    Actions(#[from] stream::error::Actions),
+

+
    /// CobStream error.
+
    #[error(transparent)]
+
    CobStream(#[from] stream::error::Stream),
+

+
    /// Inbox error.
+
    #[error(transparent)]
+
    Inbox(#[from] radicle::node::notifications::Error),
+

    /// Crypto error.
    #[error(transparent)]
    Crypto(#[from] radicle::crypto::ssh::keystore::Error),
modified crates/radicle-types/src/lib.rs
@@ -9,7 +9,9 @@ use traits::Profile;
pub mod cobs;
pub mod config;
pub mod diff;
+
pub mod domain;
pub mod error;
+
pub mod outbound;
pub mod repo;
pub mod syntax;
pub mod test;
added crates/radicle-types/src/outbound.rs
@@ -0,0 +1 @@
+
pub mod sqlite;
added crates/radicle-types/src/outbound/sqlite.rs
@@ -0,0 +1,86 @@
+
use std::collections::BTreeMap;
+
use std::path::Path;
+
use std::sync::Arc;
+
use std::time;
+

+
use radicle::{git, identity};
+
use sqlite as sql;
+

+
use crate::domain::inbox::models::notification;
+
use crate::domain::inbox::traits::InboxStorage;
+
use crate::error::Error;
+

+
pub struct Sqlite {
+
    pub db: Arc<sql::ConnectionThreadSafe>,
+
}
+

+
impl Sqlite {
+
    /// How long to wait for the database lock to be released before failing a read.
+
    const DB_READ_TIMEOUT: time::Duration = time::Duration::from_secs(3);
+

+
    pub fn reader<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+
        let mut db = sql::Connection::open_thread_safe_with_flags(
+
            path,
+
            sqlite::OpenFlags::new().with_read_only(),
+
        )?;
+
        db.set_busy_timeout(Self::DB_READ_TIMEOUT.as_millis() as usize)?;
+

+
        Ok(Self { db: Arc::new(db) })
+
    }
+
}
+

+
impl InboxStorage for Sqlite {
+
    fn counts_by_repo(
+
        &self,
+
    ) -> Result<
+
        impl Iterator<Item = Result<notification::CountByRepo, notification::ListNotificationsError>>,
+
        notification::ListNotificationsError,
+
    > {
+
        let stmt = self.db.prepare(
+
            "SELECT ref, repo, COUNT(*) as count
+
                 FROM `repository-notifications`
+
                 WHERE ref LIKE '%cobs%'
+
                 GROUP BY repo",
+
        )?;
+

+
        Ok(stmt.into_iter().map(|row| {
+
            let row = row?;
+
            let count = row.try_read::<i64, _>("count")? as usize;
+
            let repo = row.try_read::<identity::RepoId, _>("repo")?;
+

+
            Ok((repo, count))
+
        }))
+
    }
+

+
    fn repo_group(
+
        &self,
+
        params: notification::RepoGroupParams,
+
    ) -> Result<
+
        std::collections::BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
+
        notification::ListNotificationsError,
+
    > {
+
        let mut stmt = self.db.prepare(
+
        "SELECT ref, substr(ref, 66) ref_without_namespace, json_group_array(json_object('row_id', rowid, 'timestamp', timestamp, 'remote', substr(ref, 17, 48), 'old', old, 'new', new)) as value
+
        FROM 'repository-notifications'
+
        WHERE repo = ?
+
        GROUP BY ref_without_namespace
+
        ORDER BY timestamp DESC"
+
    )?;
+
        stmt.bind((1, &params.repo))?;
+

+
        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)?;
+

+
                Ok((reference.to_owned(), items))
+
            })
+
            .collect::<Result<
+
                BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
+
                notification::ListNotificationsError,
+
            >>()
+
    }
+
}
modified crates/radicle-types/src/repo.rs
@@ -13,6 +13,17 @@ use crate::error;
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "repo/")]
+
pub struct RepoCount {
+
    pub total: usize,
+
    pub delegate: usize,
+
    pub private: usize,
+
    pub seeding: usize,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
pub struct RepoInfo {
    pub payloads: SupportedPayloads,
    pub delegates: Vec<Author>,
modified crates/radicle-types/src/traits/repo.rs
@@ -12,7 +12,7 @@ use radicle::{git, identity};
use crate::cobs;
use crate::diff::Diff;
use crate::error::Error;
-
use crate::repo;
+
use crate::repo::{self, RepoCount};
use crate::syntax::{Highlighter, ToPretty};
use crate::traits::Profile;

@@ -22,6 +22,7 @@ pub enum Show {
    Delegate,
    All,
    Seeded,
+
    Private,
}

pub trait Repo: Profile {
@@ -41,6 +42,10 @@ pub trait Repo: Profile {
                continue;
            }

+
            if !doc.is_private() && show == Show::Private {
+
                continue;
+
            }
+

            if !doc.delegates().contains(&profile.public_key.into()) && show == Show::Delegate {
                continue;
            }
@@ -56,6 +61,42 @@ pub trait Repo: Profile {
        Ok::<_, Error>(entries)
    }

+
    fn repo_count(&self) -> Result<repo::RepoCount, Error> {
+
        let profile = self.profile();
+
        let storage = &profile.storage;
+
        let policies = profile.policies()?;
+
        let repos = storage.repositories()?;
+
        let mut total = 0;
+
        let mut delegate = 0;
+
        let mut private = 0;
+
        let mut seeding = 0;
+

+
        for RepositoryInfo { rid, doc, refs, .. } in repos {
+
            if policies.is_seeding(&rid)? {
+
                seeding += 1;
+
            }
+

+
            if doc.is_private() {
+
                private += 1;
+
            }
+

+
            if doc.delegates().contains(&profile.public_key.into()) {
+
                delegate += 1;
+
            }
+

+
            if refs.is_some() {
+
                total += 1;
+
            }
+
        }
+

+
        Ok::<_, Error>(RepoCount {
+
            total,
+
            seeding,
+
            private,
+
            delegate,
+
        })
+
    }
+

    fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
modified crates/test-http-api/src/api.rs
@@ -8,6 +8,7 @@ use axum::routing::post;
use axum::Router;
use hyper::header::CONTENT_TYPE;
use hyper::Method;
+
use radicle_types::domain::inbox::models::notification::NotificationCount;
use serde::{Deserialize, Serialize};
use tower_http::cors::{self, CorsLayer};

@@ -55,6 +56,11 @@ 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))
        .route("/diff_stats", post(diff_stats_handler))
@@ -116,6 +122,15 @@ async fn repo_root_handler(
    Ok::<_, Error>(Json(repos))
}

+
async fn repo_count_handler(State(ctx): State<Context>) -> impl IntoResponse {
+
    let repos = ctx.repo_count()?;
+
    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 package-lock.json
@@ -38,6 +38,7 @@
        "eslint-plugin-svelte": "^2.46.1",
        "execa": "^9.5.2",
        "get-port": "^7.1.0",
+
        "happy-dom": "^16.7.2",
        "hast-util-to-dom": "^4.0.0",
        "lodash": "^4.17.21",
        "marked": "^15.0.4",
@@ -55,6 +56,7 @@
        "typescript": "^5.7.2",
        "typescript-eslint": "^8.18.1",
        "vite": "^6.0.3",
+
        "vitest": "^3.0.3",
        "wait-on": "^8.0.1"
      },
      "engines": {
@@ -1598,6 +1600,112 @@
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
+
    "node_modules/@vitest/expect": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.3.tgz",
+
      "integrity": "sha512-SbRCHU4qr91xguu+dH3RUdI5dC86zm8aZWydbp961aIR7G8OYNN6ZiayFuf9WAngRbFOfdrLHCGgXTj3GtoMRQ==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/spy": "3.0.3",
+
        "@vitest/utils": "3.0.3",
+
        "chai": "^5.1.2",
+
        "tinyrainbow": "^2.0.0"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
+
    "node_modules/@vitest/mocker": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.3.tgz",
+
      "integrity": "sha512-XT2XBc4AN9UdaxJAeIlcSZ0ILi/GzmG5G8XSly4gaiqIvPV3HMTSIDZWJVX6QRJ0PX1m+W8Cy0K9ByXNb/bPIA==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/spy": "3.0.3",
+
        "estree-walker": "^3.0.3",
+
        "magic-string": "^0.30.17"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      },
+
      "peerDependencies": {
+
        "msw": "^2.4.9",
+
        "vite": "^5.0.0 || ^6.0.0"
+
      },
+
      "peerDependenciesMeta": {
+
        "msw": {
+
          "optional": true
+
        },
+
        "vite": {
+
          "optional": true
+
        }
+
      }
+
    },
+
    "node_modules/@vitest/pretty-format": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.3.tgz",
+
      "integrity": "sha512-gCrM9F7STYdsDoNjGgYXKPq4SkSxwwIU5nkaQvdUxiQ0EcNlez+PdKOVIsUJvh9P9IeIFmjn4IIREWblOBpP2Q==",
+
      "dev": true,
+
      "dependencies": {
+
        "tinyrainbow": "^2.0.0"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
+
    "node_modules/@vitest/runner": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.3.tgz",
+
      "integrity": "sha512-Rgi2kOAk5ZxWZlwPguRJFOBmWs6uvvyAAR9k3MvjRvYrG7xYvKChZcmnnpJCS98311CBDMqsW9MzzRFsj2gX3g==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/utils": "3.0.3",
+
        "pathe": "^2.0.1"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
+
    "node_modules/@vitest/snapshot": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.3.tgz",
+
      "integrity": "sha512-kNRcHlI4txBGztuJfPEJ68VezlPAXLRT1u5UCx219TU3kOG2DplNxhWLwDf2h6emwmTPogzLnGVwP6epDaJN6Q==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/pretty-format": "3.0.3",
+
        "magic-string": "^0.30.17",
+
        "pathe": "^2.0.1"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
+
    "node_modules/@vitest/spy": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.3.tgz",
+
      "integrity": "sha512-7/dgux8ZBbF7lEIKNnEqQlyRaER9nkAL9eTmdKJkDO3hS8p59ATGwKOCUDHcBLKr7h/oi/6hP+7djQk8049T2A==",
+
      "dev": true,
+
      "dependencies": {
+
        "tinyspy": "^3.0.2"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
+
    "node_modules/@vitest/utils": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.3.tgz",
+
      "integrity": "sha512-f+s8CvyzPtMFY1eZKkIHGhPsQgYo5qCm6O8KZoim9qm1/jT64qBgGpO5tHscNH6BzRHM+edLNOP+3vO8+8pE/A==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/pretty-format": "3.0.3",
+
        "loupe": "^3.1.2",
+
        "tinyrainbow": "^2.0.0"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
    "node_modules/@wooorm/starry-night": {
      "version": "3.5.0",
      "resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.5.0.tgz",
@@ -1690,6 +1798,15 @@
        "node": ">= 0.4"
      }
    },
+
    "node_modules/assertion-error": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+
      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12"
+
      }
+
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1809,6 +1926,15 @@
        "ieee754": "^1.2.1"
      }
    },
+
    "node_modules/cac": {
+
      "version": "6.7.14",
+
      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+
      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=8"
+
      }
+
    },
    "node_modules/callsites": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1818,6 +1944,22 @@
        "node": ">=6"
      }
    },
+
    "node_modules/chai": {
+
      "version": "5.1.2",
+
      "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
+
      "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
+
      "dev": true,
+
      "dependencies": {
+
        "assertion-error": "^2.0.1",
+
        "check-error": "^2.1.1",
+
        "deep-eql": "^5.0.1",
+
        "loupe": "^3.1.0",
+
        "pathval": "^2.0.0"
+
      },
+
      "engines": {
+
        "node": ">=12"
+
      }
+
    },
    "node_modules/chalk": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
@@ -1830,6 +1972,15 @@
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
+
    "node_modules/check-error": {
+
      "version": "2.1.1",
+
      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+
      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">= 16"
+
      }
+
    },
    "node_modules/chokidar": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.2.tgz",
@@ -1934,6 +2085,15 @@
        }
      }
    },
+
    "node_modules/deep-eql": {
+
      "version": "5.0.2",
+
      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+
      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=6"
+
      }
+
    },
    "node_modules/deep-is": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1967,6 +2127,12 @@
        "@types/trusted-types": "^2.0.7"
      }
    },
+
    "node_modules/es-module-lexer": {
+
      "version": "1.6.0",
+
      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz",
+
      "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==",
+
      "dev": true
+
    },
    "node_modules/esbuild": {
      "version": "0.24.0",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
@@ -2248,6 +2414,15 @@
        "node": ">=4.0"
      }
    },
+
    "node_modules/estree-walker": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+
      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/estree": "^1.0.0"
+
      }
+
    },
    "node_modules/esutils": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -2283,6 +2458,15 @@
        "url": "https://github.com/sindresorhus/execa?sponsor=1"
      }
    },
+
    "node_modules/expect-type": {
+
      "version": "1.1.0",
+
      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
+
      "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12.0.0"
+
      }
+
    },
    "node_modules/extend-shallow": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
@@ -2573,6 +2757,19 @@
      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
      "dev": true
    },
+
    "node_modules/happy-dom": {
+
      "version": "16.7.2",
+
      "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.7.2.tgz",
+
      "integrity": "sha512-zOzw0xyYlDaF/ylwbAsduYZZVRTd5u7IwlFkGbEathIeJMLp3vrN3cHm3RS7PZpD9gr/IO16bHEswcgNyWTsqw==",
+
      "dev": true,
+
      "dependencies": {
+
        "webidl-conversions": "^7.0.0",
+
        "whatwg-mimetype": "^3.0.0"
+
      },
+
      "engines": {
+
        "node": ">=18.0.0"
+
      }
+
    },
    "node_modules/has-flag": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2929,6 +3126,12 @@
      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
      "dev": true
    },
+
    "node_modules/loupe": {
+
      "version": "3.1.2",
+
      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
+
      "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
+
      "dev": true
+
    },
    "node_modules/magic-string": {
      "version": "0.30.17",
      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -3226,6 +3429,21 @@
        "node": ">=8"
      }
    },
+
    "node_modules/pathe": {
+
      "version": "2.0.2",
+
      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
+
      "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
+
      "dev": true
+
    },
+
    "node_modules/pathval": {
+
      "version": "2.0.0",
+
      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
+
      "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">= 14.16"
+
      }
+
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3651,6 +3869,12 @@
        "node": ">=8"
      }
    },
+
    "node_modules/siginfo": {
+
      "version": "2.0.0",
+
      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+
      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+
      "dev": true
+
    },
    "node_modules/signal-exit": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -3672,6 +3896,18 @@
        "node": ">=0.10.0"
      }
    },
+
    "node_modules/stackback": {
+
      "version": "0.0.2",
+
      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+
      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+
      "dev": true
+
    },
+
    "node_modules/std-env": {
+
      "version": "3.8.0",
+
      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz",
+
      "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==",
+
      "dev": true
+
    },
    "node_modules/strip-bom-string": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
@@ -3836,6 +4072,45 @@
        "url": "https://opencollective.com/eslint"
      }
    },
+
    "node_modules/tinybench": {
+
      "version": "2.9.0",
+
      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+
      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+
      "dev": true
+
    },
+
    "node_modules/tinyexec": {
+
      "version": "0.3.2",
+
      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+
      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+
      "dev": true
+
    },
+
    "node_modules/tinypool": {
+
      "version": "1.0.2",
+
      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
+
      "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+
      "dev": true,
+
      "engines": {
+
        "node": "^18.0.0 || >=20.0.0"
+
      }
+
    },
+
    "node_modules/tinyrainbow": {
+
      "version": "2.0.0",
+
      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+
      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=14.0.0"
+
      }
+
    },
+
    "node_modules/tinyspy": {
+
      "version": "3.0.2",
+
      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+
      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=14.0.0"
+
      }
+
    },
    "node_modules/to-regex-range": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -4050,6 +4325,28 @@
        }
      }
    },
+
    "node_modules/vite-node": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.3.tgz",
+
      "integrity": "sha512-0sQcwhwAEw/UJGojbhOrnq3HtiZ3tC7BzpAa0lx3QaTX0S3YX70iGcik25UBdB96pmdwjyY2uyKNYruxCDmiEg==",
+
      "dev": true,
+
      "dependencies": {
+
        "cac": "^6.7.14",
+
        "debug": "^4.4.0",
+
        "es-module-lexer": "^1.6.0",
+
        "pathe": "^2.0.1",
+
        "vite": "^5.0.0 || ^6.0.0"
+
      },
+
      "bin": {
+
        "vite-node": "vite-node.mjs"
+
      },
+
      "engines": {
+
        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      }
+
    },
    "node_modules/vite/node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4078,6 +4375,71 @@
        }
      }
    },
+
    "node_modules/vitest": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.3.tgz",
+
      "integrity": "sha512-dWdwTFUW9rcnL0LyF2F+IfvNQWB0w9DERySCk8VMG75F8k25C7LsZoh6XfCjPvcR8Nb+Lqi9JKr6vnzH7HSrpQ==",
+
      "dev": true,
+
      "dependencies": {
+
        "@vitest/expect": "3.0.3",
+
        "@vitest/mocker": "3.0.3",
+
        "@vitest/pretty-format": "^3.0.3",
+
        "@vitest/runner": "3.0.3",
+
        "@vitest/snapshot": "3.0.3",
+
        "@vitest/spy": "3.0.3",
+
        "@vitest/utils": "3.0.3",
+
        "chai": "^5.1.2",
+
        "debug": "^4.4.0",
+
        "expect-type": "^1.1.0",
+
        "magic-string": "^0.30.17",
+
        "pathe": "^2.0.1",
+
        "std-env": "^3.8.0",
+
        "tinybench": "^2.9.0",
+
        "tinyexec": "^0.3.2",
+
        "tinypool": "^1.0.2",
+
        "tinyrainbow": "^2.0.0",
+
        "vite": "^5.0.0 || ^6.0.0",
+
        "vite-node": "3.0.3",
+
        "why-is-node-running": "^2.3.0"
+
      },
+
      "bin": {
+
        "vitest": "vitest.mjs"
+
      },
+
      "engines": {
+
        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+
      },
+
      "funding": {
+
        "url": "https://opencollective.com/vitest"
+
      },
+
      "peerDependencies": {
+
        "@edge-runtime/vm": "*",
+
        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+
        "@vitest/browser": "3.0.3",
+
        "@vitest/ui": "3.0.3",
+
        "happy-dom": "*",
+
        "jsdom": "*"
+
      },
+
      "peerDependenciesMeta": {
+
        "@edge-runtime/vm": {
+
          "optional": true
+
        },
+
        "@types/node": {
+
          "optional": true
+
        },
+
        "@vitest/browser": {
+
          "optional": true
+
        },
+
        "@vitest/ui": {
+
          "optional": true
+
        },
+
        "happy-dom": {
+
          "optional": true
+
        },
+
        "jsdom": {
+
          "optional": true
+
        }
+
      }
+
    },
    "node_modules/vscode-oniguruma": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
@@ -4119,6 +4481,24 @@
        "url": "https://github.com/sponsors/wooorm"
      }
    },
+
    "node_modules/webidl-conversions": {
+
      "version": "7.0.0",
+
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+
      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12"
+
      }
+
    },
+
    "node_modules/whatwg-mimetype": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+
      "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12"
+
      }
+
    },
    "node_modules/which": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4134,6 +4514,22 @@
        "node": ">= 8"
      }
    },
+
    "node_modules/why-is-node-running": {
+
      "version": "2.3.0",
+
      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+
      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+
      "dev": true,
+
      "dependencies": {
+
        "siginfo": "^2.0.0",
+
        "stackback": "0.0.2"
+
      },
+
      "bin": {
+
        "why-is-node-running": "cli.js"
+
      },
+
      "engines": {
+
        "node": ">=8"
+
      }
+
    },
    "node_modules/word-wrap": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
modified package.json
@@ -14,6 +14,7 @@
    "check": "scripts/check-js && scripts/check-rs",
    "check-js": "scripts/check-js",
    "check-rs": "scripts/check-rs",
+
    "test:unit": "TZ='UTC' vitest run",
    "test:e2e": "TZ='UTC' playwright test",
    "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
    "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml",
@@ -52,6 +53,7 @@
    "eslint-plugin-svelte": "^2.46.1",
    "execa": "^9.5.2",
    "get-port": "^7.1.0",
+
    "happy-dom": "^16.7.2",
    "hast-util-to-dom": "^4.0.0",
    "lodash": "^4.17.21",
    "marked": "^15.0.4",
@@ -69,6 +71,7 @@
    "typescript": "^5.7.2",
    "typescript-eslint": "^8.18.1",
    "vite": "^6.0.3",
+
    "vitest": "^3.0.3",
    "wait-on": "^8.0.1"
  }
}
modified src/App.svelte
@@ -13,11 +13,12 @@

  import AuthenticationError from "@app/views/AuthenticationError.svelte";
  import CreateIssue from "@app/views/repo/CreateIssue.svelte";
-
  import Home from "@app/views/Home.svelte";
+
  import Inbox from "./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";
  import Patches from "@app/views/repo/Patches.svelte";
+
  import Repos from "./views/home/Repos.svelte";
  import { dynamicInterval, checkAuth } from "./lib/auth";

  const activeRouteStore = router.activeRouteStore;
@@ -71,7 +72,9 @@
{#if $activeRouteStore.resource === "booting"}
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
-
  <Home {...$activeRouteStore.params} />
+
  <Repos {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "inbox"}
+
  <Inbox {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.createIssue"}
  <CreateIssue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issue"}
modified src/components/Avatar.svelte
@@ -1,11 +1,7 @@
<script lang="ts">
  import { createIcon } from "@app/lib/blockies";

-
  const {
-
    publicKey,
-
  }: {
-
    publicKey: string;
-
  } = $props();
+
  const { publicKey }: { publicKey: string } = $props();

  function createContainer(source: string) {
    const seed = source.toLowerCase();
modified src/components/Changeset/FileDiff.svelte
@@ -4,7 +4,7 @@
  import type { Modification } from "@bindings/diff/Modification";

  import File from "@app/components/File.svelte";
-
  import { escape } from "lodash";
+
  import escape from "lodash/escape";

  interface Props {
    filePath: string;
modified src/components/Header.svelte
@@ -55,9 +55,7 @@
        <NakedButton
          variant="ghost"
          onclick={() => {
-
            void router.push({
-
              resource: "home",
-
            });
+
            void router.push({ resource: "home" });
          }}>
          <Avatar {publicKey} />
        </NakedButton>
added src/components/HomeSidebar.svelte
@@ -0,0 +1,202 @@
+
<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 * 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>;
+
    repoCount: RepoCount;
+
  }
+

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

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
    justify-content: space-between;
+
  }
+
  .tab {
+
    align-items: center;
+
    gap: 0.5rem;
+
    background-color: var(--color-background-float);
+
    clip-path: var(--1px-corner-fill);
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    justify-content: space-between;
+
    padding: 8px 4px 8px 8px;
+
    width: 100%;
+
  }
+
  .tab:not(.active) {
+
    color: var(--color-foreground-dim);
+
  }
+
  .tab:not(.active):hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .active {
+
    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="0.5rem">
+
      {#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>
+
        </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 === "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>
+
      </Border>
+
    {:else}
+
      <Border
+
        styleBackgroundColor="var(--color-background-float)"
+
        variant="float">
+
        <Link
+
          styleWidth="100%"
+
          underline={false}
+
          route={{
+
            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}
+
  </div>
+

+
  <Settings
+
    compact={false}
+
    popoverProps={{
+
      popoverPositionBottom: "3rem",
+
      popoverPositionLeft: "0",
+
    }} />
+
</div>
modified src/components/Icon.svelte
@@ -3,7 +3,7 @@

  interface Props {
    size?: "16" | "32";
-
    onclick?: () => void;
+
    onclick?: (e: MouseEvent) => void;
    disabled?: boolean;
    styleDisplay?: string;
    styleVerticalAlign?: string;
@@ -12,6 +12,9 @@
      | "arrow-right"
      | "arrow-right-hollow"
      | "attachment"
+
      | "branch"
+
      | "broom"
+
      | "broom-double"
      | "checkmark"
      | "chevron-down"
      | "chevron-right"
@@ -95,9 +98,9 @@
  class:hoverable={onclick}
  class:disabled
  role="img"
-
  onclick={() => {
+
  onclick={e => {
    if (onclick && !disabled) {
-
      onclick();
+
      onclick(e);
    }
  }}
  aria-label={`icon-${name}`}
@@ -156,6 +159,47 @@
    <path d="M4 8H5V9L4 9V8Z" />
    <path d="M5 6H11V7H5V6Z" />
    <path d="M4 7H5L5 8H4L4 7Z" />
+
  {:else if name === "branch"}
+
    <path d="M11 5L10 5V2L13 2V5L12 5V8L11 8V5ZM11 3H12V4H11V3Z" />
+
    <path
+
      d="M11 9L5 9L5 11H6L6 14H3L3 11H4L4 5H3L3 2L6 2L6 5H5L5 8H11V9ZM4 4L5 4V3H4L4 4ZM4 13V12H5L5 13H4Z" />
+
  {:else if name === "broom"}
+
    <path d="M11 13H12V14H11V13Z" />
+
    <path d="M11 13H12V14H11V13Z" />
+
    <path d="M11 12H12V13H11V12Z" />
+
    <path d="M10 12H11V14H10V12Z" />
+
    <path d="M8 12H9V14H8V12Z" />
+
    <path d="M4 12H11V14H4V12Z" />
+
    <path d="M9 12H10V13H9V12Z" />
+
    <path d="M7 12H8V13H7V12Z" />
+
    <path d="M4 10H12V11H4V10Z" />
+
    <path d="M7 3H8V10H7V3Z" />
+
    <path d="M8 3H9V10H8V3Z" />
+
    <path d="M7 2H9V3H7V2Z" />
+
  {:else if name === "broom-double"}
+
    <path d="M9 13H10V14H9V13Z" />
+
    <path d="M9 13H10V14H9V13Z" />
+
    <path d="M9 12H10V13H9V12Z" />
+
    <path d="M8 12H9V14H8V12Z" />
+
    <path d="M6 12H7V14H6V12Z" />
+
    <path d="M2 12H9V14H2V12Z" />
+
    <path d="M7 12H8V13H7V12Z" />
+
    <path d="M5 12H6V13H5V12Z" />
+
    <path d="M2 10H10V11H2V10Z" />
+
    <path d="M5 3H6V10H5V3Z" />
+
    <path d="M6 3H7V10H6V3Z" />
+
    <path d="M5 2H7V3H5V2Z" />
+
    <path d="M13 13H14V14H13V13Z" />
+
    <path d="M13 13H14V14H13V13Z" />
+
    <path d="M13 12H14V13H13V12Z" />
+
    <path d="M12 12H13V14H12V12Z" />
+
    <path d="M11 12H13V14H11V12Z" />
+
    <path d="M11 12H12V13H11V12Z" />
+
    <path d="M9 12H10V13H9V12Z" />
+
    <path d="M11 10H14V11H11V10Z" />
+
    <path d="M9 3H10V9H9V3Z" />
+
    <path d="M10 3H11V9H10V3Z" />
+
    <path d="M9 2H11V3H9V2Z" />
  {:else if name === "checkmark"}
    <path d="M7 11V12H6V11H7Z" />
    <path d="M8 10V11L7 11L7 10H8Z" />
modified src/components/Id.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { ComponentProps, Snippet } from "svelte";

-
  import { debounce } from "lodash";
+
  import debounce from "lodash/debounce";
  import { writeToClipboard } from "@app/lib/invoke";

  import { formatOid } from "@app/lib/utils";
added src/components/NotificationTeaser.svelte
@@ -0,0 +1,227 @@
+
<script lang="ts">
+
  import type { ActionWithAuthor } from "@bindings/cob/inbox/ActionWithAuthor";
+
  import type { Action as IssueAction } from "@bindings/cob/issue/Action";
+
  import type { Action as PatchAction } from "@bindings/cob/patch/Action";
+
  import type { ComponentProps } from "svelte";
+
  import type { RepoRoute } from "@app/views/repo/router";
+
  import type { NotificationItem } from "@bindings/cob/inbox/NotificationItem";
+

+
  import {
+
    authorForNodeId,
+
    formatTimestamp,
+
    issueStatusBackgroundColor,
+
    issueStatusColor,
+
    patchStatusBackgroundColor,
+
    patchStatusColor,
+
  } from "@app/lib/utils";
+
  import { compressActions } from "@app/lib/notification";
+
  import { push } from "@app/lib/router";
+
  import uniqWith from "lodash/uniqWith";
+

+
  import Icon from "./Icon.svelte";
+
  import InlineTitle from "./InlineTitle.svelte";
+
  import NodeId from "./NodeId.svelte";
+

+
  interface Props {
+
    rid: string;
+
    kind: "issue" | "patch";
+
    oid: string;
+
    clearByIds: (rid: string, ids: string[]) => Promise<void>;
+
    notificationItems: NotificationItem[];
+
    selected?: boolean;
+
  }
+

+
  const {
+
    clearByIds,
+
    notificationItems,
+
    rid,
+
    oid,
+
    kind,
+
    selected = false,
+
  }: Props = $props();
+

+
  type Action = ActionWithAuthor<IssueAction> | ActionWithAuthor<PatchAction>;
+

+
  const uniqueActions = $derived.by(() => {
+
    return compressActions(
+
      uniqWith(
+
        notificationItems.flatMap<Action>(n => n.actions),
+
        (a, b) =>
+
          Boolean(
+
            a.oid === b.oid &&
+
              a.type === b.type &&
+
              a.author.did &&
+
              b.author.did,
+
          ),
+
      ).sort((a, b) => b.timestamp - a.timestamp),
+
      kind,
+
      oid,
+
    );
+
  });
+

+
  const clearIcon = $derived(
+
    uniqueActions.length > 1 ? "broom-double" : "broom",
+
  );
+

+
  const title = $derived.by(() => {
+
    const lastDetail = notificationItems.at(-1);
+
    if (lastDetail && "title" in lastDetail) {
+
      return lastDetail.title;
+
    }
+
  });
+

+
  const icon: ComponentProps<typeof Icon>["name"] = $derived.by(() => {
+
    const lastDetail = notificationItems.at(-1);
+
    if (lastDetail?.type === "issue" && lastDetail.status.status !== "open") {
+
      return `issue-${lastDetail.status.status}` as const;
+
    } else if (lastDetail?.type === "issue") {
+
      return "issue" as const;
+
    } else if (
+
      lastDetail?.type === "patch" &&
+
      lastDetail.status.status !== "open"
+
    ) {
+
      return `patch-${lastDetail.status.status}` as const;
+
    } else {
+
      return "patch" as const;
+
    }
+
  });
+

+
  const statusColor = $derived.by(() => {
+
    const lastDetail = notificationItems.at(-1);
+
    if (lastDetail?.type === "patch") {
+
      return {
+
        color: patchStatusColor[lastDetail.status.status],
+
        background: patchStatusBackgroundColor[lastDetail.status.status],
+
      };
+
    } else if (lastDetail?.type === "issue") {
+
      return {
+
        color: issueStatusColor[lastDetail.status.status],
+
        background: issueStatusBackgroundColor[lastDetail.status.status],
+
      };
+
    } else {
+
      return {
+
        color: "var(--color-foreground-dim)",
+
        background: "var(--color-fill-ghost)",
+
      };
+
    }
+
  });
+

+
  const route = $derived.by(() => {
+
    const lastDetail = notificationItems.at(-1);
+
    switch (lastDetail?.type) {
+
      case "patch":
+
        return {
+
          resource: "repo.patch",
+
          rid,
+
          patch: lastDetail.id,
+
          status: undefined,
+
        } as RepoRoute;
+
      case "issue":
+
        return {
+
          resource: "repo.issue",
+
          rid,
+
          issue: lastDetail.id,
+
          status: "all",
+
        } as RepoRoute;
+
    }
+

+
    return undefined;
+
  });
+
</script>
+

+
<style>
+
  .notification-teaser {
+
    display: flex;
+
    align-items: center;
+
    justify-content: space-between;
+
    gap: 0.25rem;
+
    min-height: 5rem;
+
    background-color: var(--color-background-float);
+
    padding: 1rem;
+
    cursor: pointer;
+
    font-size: var(--font-size-regular);
+
    word-break: break-word;
+
  }
+
  .selected {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .notification-teaser:hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .status {
+
    padding: 0;
+
    margin-right: 1rem;
+
  }
+
  .icon {
+
    width: 2rem;
+
    height: 2rem;
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
  }
+
  .notification-teaser:first-of-type {
+
    clip-path: var(--3px-top-corner-fill);
+
  }
+
  .notification-teaser:last-of-type {
+
    clip-path: var(--3px-bottom-corner-fill);
+
  }
+
  .notification-teaser:only-of-type {
+
    clip-path: var(--3px-corner-fill);
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  tabindex="0"
+
  role="button"
+
  class:selected
+
  class="notification-teaser"
+
  onclick={() => {
+
    if (route) void push(route);
+
  }}>
+
  <div
+
    class="global-flex"
+
    style:justify-content="space-between"
+
    style:align-items="flex-start"
+
    style:width="100%">
+
    <div class="global-flex">
+
      <div
+
        class="global-counter status"
+
        style:align-self="flex-start"
+
        style:color={statusColor.color}
+
        style:background-color={statusColor.background}>
+
        <Icon name={icon} />
+
      </div>
+
      <div
+
        class="global-flex"
+
        style:flex-direction="column"
+
        style:align-items="flex-start">
+
        {#if title}
+
          <InlineTitle content={title} />
+
        {/if}
+
        <div class="txt-small">
+
          {#each uniqueActions as action}
+
            <div class="global-flex" style:gap="0.25rem" style:height="2rem">
+
              <NodeId {...authorForNodeId(action.items[0].author)} />
+
              <span>{@html action.summary}</span>
+
              <span>{formatTimestamp(action.items[0].timestamp)}</span>
+
            </div>
+
          {/each}
+
        </div>
+
      </div>
+
    </div>
+
    <div class="global-flex">
+
      <div class="icon">
+
        <Icon
+
          onclick={e => {
+
            e.stopPropagation();
+
            void clearByIds(
+
              rid,
+
              notificationItems.map(n => n.rowId),
+
            );
+
          }}
+
          name={clearIcon} />
+
      </div>
+
    </div>
+
  </div>
+
</div>
added src/components/RepoNotifications.svelte
@@ -0,0 +1,74 @@
+
<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 NotificationTeaser from "./NotificationTeaser.svelte";
+
  import Icon from "./Icon.svelte";
+

+
  interface Props {
+
    all?: boolean;
+
    clearByIds: (rid: string, ids: string[]) => Promise<void>;
+
    clearByRepo: (rid: string) => Promise<void>;
+
    repo: HomeInboxTab;
+
    items: Record<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: 1.5rem;
+
  }
+
</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>
+
    <Icon onclick={() => clearByRepo(repo.rid)} name="broom-double" />
+
  </div>
+
{/if}
+

+
<div>
+
  {#each Object.entries(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}
added src/lib/notification.ts
@@ -0,0 +1,188 @@
+
import type { ActionWithAuthor } from "@bindings/cob/inbox/ActionWithAuthor";
+
import type { Action as IssueAction } from "@bindings/cob/issue/Action";
+
import type { Action as PatchAction } from "@bindings/cob/patch/Action";
+

+
import { pluralize, formatOid } from "@app/lib/utils";
+

+
export type Action =
+
  | ActionWithAuthor<IssueAction>
+
  | ActionWithAuthor<PatchAction>;
+

+
// N.b. I have taken the `%` char as indicator for a `global-oid` class
+
export function createSummary(
+
  a: Action[],
+
  kind: "issue" | "patch",
+
  oid: string,
+
  count: number,
+
) {
+
  const lastAction = a[a.length - 1];
+
  let summary = `${lastAction.type} not implemented!`;
+

+
  function times(count: number) {
+
    return count > 1 ? `${count} times` : "";
+
  }
+

+
  if (lastAction.oid === oid) {
+
    summary = `opened ${kind} %${formatOid(lastAction.oid)}%`;
+
  } else if (lastAction.type === "comment") {
+
    summary = `left ${count > 1 ? count : "a"} ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "revision") {
+
    const revisions = a.map(i => `%${formatOid(i.oid)}%`).slice(0, 10);
+
    summary = `created ${pluralize("revision", count)} ${[
+
      ...revisions,
+
      ...(a.length >= 11 ? ["%…%"] : []),
+
    ].join(", ")}`;
+
  } else if (lastAction.type === "merge") {
+
    summary = `merged ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`;
+
  } else if (lastAction.type === "edit" && kind === "issue") {
+
    summary = `edited issue${count ? times(count) : ""}`;
+
  } else if (lastAction.type === "edit" && kind === "patch") {
+
    summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.oid)}%`;
+
  } else if (lastAction.type === "revision.edit") {
+
    summary = `edited ${pluralize("revision", count)} %${formatOid(lastAction.revision)}%`;
+
  } else if (lastAction.type === "lifecycle" && count > 1) {
+
    summary = `changed to ${lastAction.state.status} and ${count} more changes`;
+
  } else if (
+
    lastAction.type === "lifecycle" &&
+
    lastAction.state.status === "draft"
+
  ) {
+
    summary = `changed to ${lastAction.state.status}`;
+
  } else if (
+
    lastAction.type === "lifecycle" &&
+
    lastAction.state.status === "open"
+
  ) {
+
    summary = `reopened ${kind}`;
+
  } else if (
+
    lastAction.type === "lifecycle" &&
+
    lastAction.state.status === "archived"
+
  ) {
+
    summary = `archived ${kind}`;
+
  } else if (
+
    lastAction.type === "lifecycle" &&
+
    lastAction.state.status === "closed" &&
+
    lastAction.state.reason === "solved"
+
  ) {
+
    summary = `closed ${kind} as solved`;
+
  } else if (
+
    lastAction.type === "lifecycle" &&
+
    lastAction.state.status === "closed"
+
  ) {
+
    summary = `closed ${kind}`;
+
  } else if (lastAction.type === "revision.comment") {
+
    summary = `left ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.comment") {
+
    summary = `left ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (a.every(e => e.type === "comment.react")) {
+
    const reactions = a.map(i => i.reaction).slice(0, 10);
+
    summary = `reacted with ${[
+
      ...reactions,
+
      ...(a.length >= 11 ? ["%…%"] : []),
+
    ].join(", ")} to ${count > 1 ? count : "a"} ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "comment.edit") {
+
    summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review" && lastAction.verdict) {
+
    summary = `${lastAction.verdict}ed revision %${formatOid(lastAction.revision)}% with a review`;
+
  } else if (lastAction.type === "review") {
+
    summary = `left a review with a comment on revision %${formatOid(lastAction.revision)}%`;
+
  } else if (lastAction.type === "assign") {
+
    summary = "changed assignes";
+
  } else if (lastAction.type === "revision.comment.edit") {
+
    summary = `edited ${count > 1 ? count : "a"} ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "comment.redact") {
+
    summary = `redacted ${count > 1 ? count : "a"} ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "label") {
+
    summary = `added ${pluralize("label", count)}`;
+
  } else if (lastAction.type === "review.comment.edit") {
+
    summary = `edited ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.comment.react") {
+
    summary = `reacted to ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.comment.redact") {
+
    summary = `redacted ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.comment.resolve") {
+
    summary = `resolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.comment.unresolve") {
+
    summary = `unresolved ${count > 1 ? count : "a"} review ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "review.edit") {
+
    summary = `edited ${count > 1 ? count : "a"} ${pluralize("review", count)}`;
+
  } else if (lastAction.type === "review.redact") {
+
    summary = `redacted ${count > 1 ? count : "a"} ${pluralize("review", count)}`;
+
  } else if (lastAction.type === "revision.comment.react") {
+
    summary = `reacted to ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "revision.comment.redact") {
+
    summary = `redacted ${count > 1 ? count : "a"} revision ${pluralize("comment", count)}`;
+
  } else if (lastAction.type === "revision.react") {
+
    summary = `reacted to ${count > 1 ? count : "a"} ${pluralize("revision", count)}`;
+
  } else if (lastAction.type === "revision.redact") {
+
    summary = `redacted ${count > 1 ? count : "a"} ${pluralize("revision", count)}`;
+
  }
+

+
  return summary.replaceAll(
+
    /[%](\S+)[%]/g,
+
    '<span class="global-oid">$1</span>',
+
  );
+
}
+

+
export function compressActions(
+
  actions: Action[],
+
  kind: "issue" | "patch",
+
  oid: string,
+
) {
+
  const result: {
+
    summary: string;
+
    oid: string;
+
    items: Action[];
+
  }[] = [];
+

+
  let currentGroup: Action[] = [];
+

+
  for (const action of actions) {
+
    if (currentGroup.length === 0) {
+
      currentGroup.push(action);
+
      continue;
+
    }
+

+
    const last = currentGroup[currentGroup.length - 1];
+
    const sameAuthorDid = last.author.did === action.author.did;
+
    const sameType =
+
      last.type === action.type ||
+
      (last.type === "revision.edit" && action.type === "edit") ||
+
      (action.type === "revision.edit" && last.type === "edit") ||
+
      action.oid === oid;
+

+
    if (sameAuthorDid && sameType) {
+
      currentGroup.push(action);
+
    } else {
+
      const summaryStr = createSummary(
+
        currentGroup,
+
        kind,
+
        oid,
+
        currentGroup.length,
+
      );
+

+
      result.push({
+
        summary: summaryStr,
+
        oid,
+
        items: currentGroup,
+
      });
+

+
      currentGroup = [action];
+
    }
+
  }
+

+
  // Summarize any open actions.
+
  if (currentGroup.length > 0) {
+
    const summaryStr = createSummary(
+
      currentGroup,
+
      kind,
+
      oid,
+
      currentGroup.length,
+
    );
+
    result.push({
+
      summary: summaryStr,
+
      oid,
+
      items: currentGroup,
+
    });
+
  }
+

+
  return result;
+
}
modified src/lib/router.ts
@@ -103,7 +103,12 @@ function urlToRoute(url: URL): Route | null {

  switch (resource) {
    case "": {
-
      return { resource: "home" };
+
      return {
+
        resource: "home",
+
      };
+
    }
+
    case "inbox": {
+
      return { resource: "inbox" };
    }
    case "repos": {
      return repoUrlToRoute(segments, url.searchParams);
@@ -120,6 +125,8 @@ 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 === "authenticationError") {
    return "/authenticationError";
  } else if (
modified src/lib/router/definitions.ts
@@ -1,8 +1,13 @@
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 { LoadedRepoRoute, RepoRoute } from "@app/views/repo/router";

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

import {
  loadCreateIssue,
@@ -12,6 +17,14 @@ import {
  loadPatches,
} from "@app/views/repo/router";

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

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

interface BootingRoute {
  resource: "booting";
}
@@ -26,21 +39,53 @@ interface AuthenticationErrorRoute {

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: Record<string, NotificationItem[]>;
+
        pagination: { cursor: number; more: boolean };
+
      }
+
    >;
+
    config: Config;
+
  };
}

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

export type Route =
  | AuthenticationErrorRoute
+
  | InboxRoute
  | BootingRoute
  | HomeRoute
  | RepoRoute;

export type LoadedRoute =
  | AuthenticationErrorRoute
+
  | LoadedInboxRoute
  | BootingRoute
  | LoadedHomeRoute
  | LoadedRepoRoute;
@@ -49,12 +94,77 @@ 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") {
-
    const [config, repos] = await Promise.all([
-
      invoke<Config>("config"),
-
      invoke<RepoInfo[]>("list_repos", { show: "all" }),
-
    ]);
-
    return { resource: "home", params: { repos, config } };
+
    let show = "all";
+

+
    if (route.resource === "home") {
+
      if (route.activeTab === "delegate") {
+
        show = "delegate";
+
      } 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<Record<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<Record<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,
+
      },
+
    };
  } else if (route.resource === "repo.issue") {
    return loadIssue(route);
  } else if (route.resource === "repo.createIssue") {
modified src/lib/utils.ts
@@ -47,6 +47,10 @@ export function truncateDid(did: string): string {
  return `did:key:${truncateId(publicKeyFromDid(did))}`;
}

+
export function didFromPublicKey(publicKey: string) {
+
  return `did:key:${publicKey}`;
+
}
+

export function publicKeyFromDid(did: string) {
  return did.replace("did:key:", "");
}
@@ -165,6 +169,10 @@ export function formatEditedCaption(author: Author, timestamp: number) {
  return `${author.alias ? author.alias : truncateDid(author.did)} edited ${absoluteTimestamp(timestamp)}`;
}

+
export function pluralize(singular: string, count: number): string {
+
  return count === 1 ? singular : `${singular}s`;
+
}
+

export function isMac() {
  if (
    (navigator.platform && navigator.platform.includes("Mac")) ||
deleted src/views/Home.svelte
@@ -1,70 +0,0 @@
-
<script lang="ts">
-
  import type { Config } from "@bindings/config/Config";
-
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
-

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

-
  import CopyableId from "@app/components/CopyableId.svelte";
-
  import Header from "@app/components/Header.svelte";
-
  import RepoCard from "@app/components/RepoCard.svelte";
-
  import Settings from "@app/components/Settings.svelte";
-

-
  interface Props {
-
    repos: RepoInfo[];
-
    config: Config;
-
  }
-

-
  const { repos, config }: Props = $props();
-
</script>
-

-
<style>
-
  .header {
-
    position: sticky;
-
    top: 0;
-
    z-index: 1;
-
  }
-
  .repo-grid {
-
    display: grid;
-
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
-
    gap: 1rem;
-
  }
-
</style>
-

-
<div style:height="fit-content">
-
  <div class="header">
-
    <Header publicKey={config.publicKey}>
-
      {#snippet center()}
-
        <CopyableId id={`did:key:${config.publicKey}`} />
-
      {/snippet}
-
      {#snippet settingsButton()}
-
        <Settings
-
          styleHeight="32px"
-
          popoverProps={{
-
            popoverPositionRight: "0",
-
            popoverPositionTop: "2.5rem",
-
          }} />
-
      {/snippet}
-
    </Header>
-
  </div>
-
  <div style:padding="1rem">
-
    <div class="txt-semibold" style:margin="0.5rem 0 1.5rem 1rem">
-
      Repositories
-
    </div>
-
    <div class="repo-grid">
-
      {#each repos as repo}
-
        {#if repo.payloads["xyz.radicle.project"]}
-
          <RepoCard
-
            {repo}
-
            selfDid={`did:key:${config.publicKey}`}
-
            onclick={() => {
-
              void router.push({
-
                resource: "repo.issues",
-
                rid: repo.rid,
-
                status: "open",
-
              });
-
            }} />
-
        {/if}
-
      {/each}
-
    </div>
-
  </div>
-
</div>
added src/views/home/Inbox.svelte
@@ -0,0 +1,215 @@
+
<script lang="ts">
+
  import type { HomeInboxTab } from "@app/lib/router/definitions";
+
  import type { Config } from "@bindings/config/Config";
+
  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 { SvelteMap } from "svelte/reactivity";
+
  import { invoke } from "@app/lib/invoke";
+
  import * as router from "@app/lib/router";
+

+
  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 Border from "@app/components/Border.svelte";
+

+
  interface Props {
+
    activeTab?: HomeInboxTab;
+
    notificationCount: SvelteMap<string, NotificationCount>;
+
    notifications: SvelteMap<
+
      string,
+
      {
+
        repo: HomeInboxTab;
+
        items: Record<string, NotificationItem[]>;
+
        pagination: { cursor: number; more: boolean };
+
      }
+
    >;
+
    repoCount: RepoCount;
+
    config: Config;
+
  }
+

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

+
  let cursor: number | undefined = undefined;
+
  let more: boolean | undefined = undefined;
+

+
  // 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();
+
    }
+
  }
+

+
  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]);
+
    }
+
  }
+

+
  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]);
+
    }
+
  }
+

+
  async function reload(rids: string[]) {
+
    for (const rid of rids) {
+
      const [n, count] = await Promise.all([
+
        invoke<PaginatedQuery<Record<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" });
+
      }
+
    }
+
  }
+

+
  async function loadMoreContent() {
+
    if (more && activeTab) {
+
      const c = cursor ? cursor : 20;
+
      const p = await invoke<
+
        PaginatedQuery<Record<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 },
+
      });
+
    }
+
  }
+
</script>
+

+
<style>
+
  .container {
+
    padding: 1rem 1rem 1rem 0;
+
  }
+
  .header {
+
    font-weight: var(--font-weight-medium);
+
    font-size: var(--font-size-medium);
+
    display: flex;
+
    justify-content: space-between;
+
    padding-right: 1.5rem;
+
    align-items: center;
+
    min-height: 40px;
+
  }
+
</style>
+

+
<Layout
+
  loadMoreContent={async () => {
+
    if (activeTab) {
+
      await loadMoreContent();
+
    }
+
  }}
+
  hideSidebar
+
  styleSecondColumnOverflow="visible"
+
  publicKey={config.publicKey}>
+
  {#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}
+
        <Icon onclick={clearAll} name="broom-double" />
+
      {/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="74px"
+
          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}
+
  </div>
+
</Layout>
added src/views/home/Repos.svelte
@@ -0,0 +1,99 @@
+
<script lang="ts">
+
  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 { RepoCount } from "@bindings/repo/RepoCount";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import * as router from "@app/lib/router";
+
  import { didFromPublicKey } from "@app/lib/utils";
+

+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import HomeSidebar from "@app/components/HomeSidebar.svelte";
+
  import Layout from "@app/views/repo/Layout.svelte";
+
  import RepoCard from "@app/components/RepoCard.svelte";
+
  import Border from "@app/components/Border.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+

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

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

+
<style>
+
  .container {
+
    padding: 1rem 1rem 1rem 0;
+
  }
+
  .repo-grid {
+
    display: grid;
+
    grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
+
    gap: 1rem;
+
  }
+
  .header {
+
    font-weight: var(--font-weight-medium);
+
    font-size: var(--font-size-medium);
+
    display: flex;
+
    justify-content: space-between;
+
    padding-right: 1.5rem;
+
    align-items: center;
+
    min-height: 40px;
+
  }
+
</style>
+

+
<Layout
+
  hideSidebar
+
  styleSecondColumnOverflow="visible"
+
  publicKey={config.publicKey}>
+
  {#snippet headerCenter()}
+
    <CopyableId id={config.publicKey} />
+
  {/snippet}
+
  {#snippet secondColumn()}
+
    <HomeSidebar
+
      activeTab={{ type: "repos", filter: activeTab }}
+
      {repoCount}
+
      {notificationCount} />
+
  {/snippet}
+
  <div class="container">
+
    <div class="header">Repositories</div>
+
    <div class="repo-grid">
+
      {#each repos as repo}
+
        {#if repo.payloads["xyz.radicle.project"]}
+
          <RepoCard
+
            {repo}
+
            selfDid={didFromPublicKey(config.publicKey)}
+
            onclick={() => {
+
              void router.push({
+
                resource: "repo.issues",
+
                rid: repo.rid,
+
                status: "open",
+
              });
+
            }} />
+
        {/if}
+
      {:else}
+
        <Border
+
          variant="ghost"
+
          styleAlignItems="center"
+
          styleJustifyContent="center">
+
          <div
+
            class="global-flex"
+
            style:height="74px"
+
            style:justify-content="center">
+
            <div class="txt-missing txt-small global-flex" style:gap="0.25rem">
+
              <Icon name="none" />
+
              No repositories.
+
            </div>
+
          </div>
+
        </Border>
+
      {/each}
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/Layout.svelte
@@ -35,7 +35,7 @@
    publicKey: string;
    headerCenter?: Snippet;
    secondColumn: Snippet;
-
    sidebar: Snippet;
+
    sidebar?: Snippet;
    loadMoreContent?: () => Promise<void>;
    loadMoreSecondColumn?: () => Promise<void>;
    hideSidebar?: boolean;
@@ -47,7 +47,7 @@
    publicKey,
    headerCenter = undefined,
    secondColumn,
-
    sidebar,
+
    sidebar = undefined,
    loadMoreContent = undefined,
    loadMoreSecondColumn = undefined,
    hideSidebar = false,
@@ -65,7 +65,7 @@
        if (
          contentContainer &&
          contentContainer.scrollTop + contentContainer.clientHeight >=
-
            contentContainer.scrollHeight - 600 &&
+
            contentContainer.scrollHeight / 2 &&
          loadingContent === false
        ) {
          loadingContent = true;
@@ -80,7 +80,7 @@
          secondColumnContainer &&
          secondColumnContainer.scrollTop +
            secondColumnContainer.clientHeight >=
-
            secondColumnContainer.scrollHeight - 600 &&
+
            secondColumnContainer.scrollHeight / 2 &&
          loadingSecondColumn === false
        ) {
          loadingSecondColumn = true;
@@ -135,12 +135,14 @@
    <Header {publicKey} center={headerCenter}></Header>
  </div>

-
  <div
-
    class="sidebar"
-
    style:display={hideSidebar ? "none" : "flex"}
-
    style:padding-right="1rem">
-
    {@render sidebar()}
-
  </div>
+
  {#if sidebar}
+
    <div
+
      class="sidebar"
+
      style:display={hideSidebar ? "none" : "flex"}
+
      style:padding-right="1rem">
+
      {@render sidebar()}
+
    </div>
+
  {/if}

  <div
    class="secondColumn"
modified tests/e2e/authenticate.spec.ts
@@ -6,7 +6,7 @@ test("removing identities from ssh-agent and re-adding them", async ({
}) => {
  await page.goto("/");
  await expect(
-
    page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
+
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
  ).toBeVisible();

  await peer.logOut();
@@ -16,6 +16,6 @@ test("removing identities from ssh-agent and re-adding them", async ({

  await peer.authenticate();
  await expect(
-
    page.getByText("did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
+
    page.getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S"),
  ).toBeVisible();
});
added tests/unit/notifications.test.ts
@@ -0,0 +1,205 @@
+
import type { Action as NotificationAction } from "@app/lib/notification";
+
import type { Action as IssueAction } from "@bindings/cob/issue/Action";
+
import type { Action as PatchAction } from "@bindings/cob/patch/Action";
+

+
import { describe, expect, test } from "vitest";
+
import { compressActions, createSummary } from "@app/lib/notification";
+
import { formatOid } from "@app/lib/utils";
+

+
const oid = "e8f95f5082a8e99c290ab3908a926e1de5c97d6c";
+
const revision = "eb3d92ddbd4394bbd6896a99152afb1f8647d6ca";
+
const actionOid = "22d3ec4b78314f83a43add9b72382c6fbc44c8b6";
+
const timestamp = 1737622257;
+
const author = {
+
  did: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
  alias: "rudolfs",
+
};
+

+
const createAction = (
+
  action: IssueAction | PatchAction,
+
  oid = actionOid,
+
): NotificationAction => ({
+
  oid,
+
  timestamp,
+
  author,
+
  ...action,
+
});
+

+
describe("Action summaries", () => {
+
  test.each([
+
    {
+
      summary: "Review without verdict",
+
      input: [createAction({ type: "review", revision })],
+
      output: `left a review with a comment on revision <span class="global-oid">${formatOid(revision)}</span>`,
+
    },
+
    {
+
      summary: "Review with accepted verdict",
+
      input: [createAction({ type: "review", verdict: "accept", revision })],
+
      output: `accepted revision <span class="global-oid">${formatOid(revision)}</span> with a review`,
+
    },
+
    {
+
      summary: "Review with rejected verdict",
+
      input: [createAction({ type: "review", verdict: "reject", revision })],
+
      output: `rejected revision <span class="global-oid">${formatOid(revision)}</span> with a review`,
+
    },
+
    {
+
      summary: "Add multiple labels",
+
      input: [
+
        createAction({
+
          type: "label",
+
          labels: ["bug", "ux"],
+
        }),
+
        createAction({
+
          type: "label",
+
          labels: ["design"],
+
        }),
+
      ],
+
      output: "added labels",
+
    },
+
    {
+
      summary: "Leave multiple review comments",
+
      input: [
+
        createAction({
+
          type: "review.comment",
+
          body: "A review comment",
+
          review: oid,
+
        }),
+
        createAction({
+
          type: "review.comment",
+
          body: "Next review comment",
+
          review: oid,
+
        }),
+
      ],
+
      output: "left 2 review comments",
+
    },
+
  ])(
+
    "$summary => $output",
+
    ({ input, output }: { input: NotificationAction[]; output: string }) => {
+
      expect(compressActions(input, "patch", oid)[0].summary).toEqual(output);
+
    },
+
  );
+

+
  test.each([
+
    {
+
      summary: "Create new revision",
+
      input: [
+
        createAction({
+
          type: "revision",
+
          oid: revision,
+
          description: "",
+
          base: oid,
+
        }),
+
      ],
+
      output: `created revision <span class="global-oid">${formatOid(revision)}</span>`,
+
    },
+
    {
+
      summary: "Merge revision",
+
      input: [
+
        createAction({
+
          type: "merge",
+
          revision,
+
          commit: actionOid,
+
        }),
+
      ],
+
      output: `merged revision <span class="global-oid">${formatOid(revision)}</span>`,
+
    },
+
  ])(
+
    "$summary => $output",
+
    ({ input, output }: { input: NotificationAction[]; output: string }) => {
+
      expect(compressActions(input, "patch", oid)[0].summary).toEqual(output);
+
    },
+
  );
+

+
  test.each([
+
    {
+
      summary: "Close patch",
+
      input: [
+
        createAction({
+
          type: "lifecycle",
+
          state: { status: "closed", reason: "other" },
+
        }),
+
      ],
+
      output: "closed patch",
+
    },
+
    {
+
      summary: "Close patch as solved",
+
      input: [
+
        createAction({
+
          type: "lifecycle",
+
          state: { status: "closed", reason: "solved" },
+
        }),
+
      ],
+
      output: "closed patch as solved",
+
    },
+
    {
+
      summary: "Archive patch",
+
      input: [
+
        createAction({ type: "lifecycle", state: { status: "archived" } }),
+
      ],
+
      output: "archived patch",
+
    },
+
    {
+
      summary: "Reopen patch",
+
      input: [createAction({ type: "lifecycle", state: { status: "open" } })],
+
      output: "reopened patch",
+
    },
+
    {
+
      summary: "Change patch to draft",
+
      input: [createAction({ type: "lifecycle", state: { status: "draft" } })],
+
      output: "changed to draft",
+
    },
+
    {
+
      summary: "More than one lifecycle change",
+
      input: [
+
        createAction({ type: "lifecycle", state: { status: "draft" } }),
+
        createAction({ type: "lifecycle", state: { status: "open" } }),
+
      ],
+
      output: "changed to open and 2 more changes",
+
    },
+
  ])(
+
    "$summary => $output",
+
    ({ input, output }: { input: NotificationAction[]; output: string }) => {
+
      expect(createSummary(input, "patch", oid, input.length)).toEqual(output);
+
    },
+
  );
+

+
  test.each([
+
    {
+
      summary: "Open patch with an edit and a comment action",
+
      type: "patch" as const,
+
      input: [
+
        createAction({ type: "edit", title: "Lorem ipsum" }, oid),
+
        createAction({ type: "comment", body: "A patch title" }, oid),
+
      ],
+
      output: `opened patch <span class="global-oid">${formatOid(oid)}</span>`,
+
    },
+
    {
+
      summary: "Open issue where the action has the same oid than the cob",
+
      type: "issue" as const,
+
      input: [createAction({ type: "edit", title: "Lorem ipsum" }, oid)],
+
      output: `opened issue <span class="global-oid">${formatOid(oid)}</span>`,
+
    },
+
    {
+
      summary: "Leave two comments in one operation",
+
      type: "issue" as const,
+
      input: [
+
        createAction({ type: "comment", body: "Lorem ipsum" }),
+
        createAction({ type: "comment", body: "A patch title" }),
+
      ],
+
      output: `left 2 comments`,
+
    },
+
  ])(
+
    "$summary => $output",
+
    ({
+
      type,
+
      input,
+
      output,
+
    }: {
+
      type: "patch" | "issue";
+
      input: NotificationAction[];
+
      output: string;
+
    }) => {
+
      expect(createSummary(input, type, oid, input.length)).toEqual(output);
+
    },
+
  );
+
});
modified vite.config.ts
@@ -4,6 +4,11 @@ import path from "node:path";

// https://vitejs.dev/config/
export default defineConfig({
+
  test: {
+
    environment: "happy-dom",
+
    include: ["tests/unit/**/*.test.ts"],
+
    reporters: "verbose",
+
  },
  plugins: [svelte()],
  build: {
    outDir: "build",