Add global inbox

This commit is contained in:
Sebastian Martinez
2025-01-23 22:28:00 +01:00
parent dc87d3fa84
commit d5632ed534
48 changed files with 2572 additions and 152 deletions
+15
View File
@@ -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
+1
View File
@@ -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;
+181
View File
@@ -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(())
}
@@ -15,6 +15,11 @@ pub fn list_repos(
ctx.list_repos(show)
}
#[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>,
+15 -4
View File
@@ -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,
@@ -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;
@@ -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>>;
};
@@ -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 };
@@ -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;
@@ -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>>;
};
@@ -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 };
@@ -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" };
@@ -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 };
@@ -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;
};
+1 -1
View File
@@ -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/")]
+3 -3
View File
@@ -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/")]
+1 -1
View File
@@ -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/")]
+26 -7
View File
@@ -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 radicle::cob::{ObjectId, TypeName};
use radicle::git::Oid;
use radicle::profile::Aliases;
use radicle::storage::git::Repository;
use serde::Deserialize;
use radicle::cob::{ObjectId, TypeName};
use radicle::git::raw as git2;
use radicle::git::Oid;
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,
))
}
}
+62 -21
View File
@@ -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,
})
}
+3 -3
View File
@@ -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 {
@@ -1,11 +1,33 @@
use radicle::{git, identity, node, storage};
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,
pub remote: Option<storage::RemoteId>,
/// Node Id that provided us with this notification.
pub remote: storage::RemoteId,
pub old: Option<git::Oid>,
pub new: Option<git::Oid>,
}
@@ -18,9 +40,11 @@ pub struct CountsByRepoParams {
pub repo: identity::RepoId,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, serde::Deserialize)]
pub struct RepoGroupParams {
pub repo: identity::RepoId,
pub skip: Option<usize>,
pub take: Option<usize>,
}
#[derive(Debug, thiserror::Error)]
@@ -34,6 +58,9 @@ pub enum ListNotificationsError {
#[error(transparent)]
Sqlite(#[from] sqlite::Error),
#[error(transparent)]
CobStream(#[from] stream::error::Stream),
#[error(transparent)]
NotificationKindError(#[from] node::notifications::NotificationKindError),
@@ -41,3 +68,249 @@ pub enum ListNotificationsError {
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()),
},
}
}
}
+21 -1
View File
@@ -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,10 +29,22 @@ pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
/// 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),
+3 -3
View File
@@ -3,7 +3,6 @@ use std::path::Path;
use std::sync::Arc;
use std::time;
use radicle::git::Qualified;
use radicle::{git, identity};
use sqlite as sql;
@@ -38,8 +37,9 @@ impl InboxStorage for Sqlite {
notification::ListNotificationsError,
> {
let stmt = self.db.prepare(
"SELECT repo, COUNT(*) as count
"SELECT ref, repo, COUNT(*) as count
FROM `repository-notifications`
WHERE ref LIKE '%cobs%'
GROUP BY repo",
)?;
@@ -79,7 +79,7 @@ impl InboxStorage for Sqlite {
Ok((reference.to_owned(), items))
})
.collect::<Result<
BTreeMap<Qualified<'static>, Vec<notification::NotificationRow>>,
BTreeMap<git::Qualified<'static>, Vec<notification::NotificationRow>>,
notification::ListNotificationsError,
>>()
}
+11
View File
@@ -9,6 +9,17 @@ use radicle::{git, identity, issue, patch};
use crate::cobs::Author;
use crate::error;
#[derive(Serialize, TS)]
#[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)]
+42 -1
View File
@@ -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)?;
+15
View File
@@ -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,
+396
View File
@@ -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",
+3
View File
@@ -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"
}
}
+5 -2
View File
@@ -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"}
+1 -5
View File
@@ -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();
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -55,9 +55,7 @@
<NakedButton
variant="ghost"
onclick={() => {
void router.push({
resource: "home",
});
void router.push({ resource: "home" });
}}>
<Avatar {publicKey} />
</NakedButton>
+202
View File
@@ -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>
+47 -3
View File
@@ -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" />
+1 -1
View File
@@ -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";
+227
View File
@@ -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>
+74
View File
@@ -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}
+188
View File
@@ -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;
}
+8 -1
View File
@@ -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 (
+116 -6
View File
@@ -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") {
+8
View File
@@ -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")) ||
-70
View File
@@ -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>
+215
View File
@@ -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>
+99
View File
@@ -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>
+12 -10
View File
@@ -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"
+2 -2
View File
@@ -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();
});
+205
View File
@@ -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);
},
);
});
+5
View File
@@ -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",