Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
http: Remove session and r/w functionality
Archived did:key:z6MkkfM3...sVz5 opened 1 year ago

Remove session and r/w functionality

Cleanup config types

79 files changed +392 -6955 16693451 3ddf99b8
modified config/custom-environment-variables.json
@@ -3,7 +3,6 @@
    "fallbackPublicExplorer": "FALLBACK_PUBLIC_EXPLORER",
    "apiVersion": "API_VERSION",
    "defaultHttpdPort": "DEFAULT_HTTPD_PORT",
-
    "defaultLocalHttpdPort": "DEFAULT_LOCAL_HTTPD_PORT",
    "defaultHttpdHostname": "DEFAULT_HTTPD_HOSTNAME",
    "defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME",
    "defaultNodePort": "DEFAULT_NODE_PORT",
@@ -13,10 +12,6 @@
    }
  },
  "supportWebsite": "SUPPORT_WEBSITE",
-
  "reactions": {
-
    "__name": "REACTIONS",
-
    "__format": "json"
-
  },
  "fallbackPreferredSeed": {
    "__name": "FALLBACK_PREFERRED_SEED",
    "__format": "json"
modified config/default.json
@@ -1,9 +1,8 @@
{
  "nodes": {
    "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
-
    "apiVersion": "1.2.0",
+
    "apiVersion": "2.0.0",
    "defaultHttpdPort": 443,
-
    "defaultLocalHttpdPort": 8080,
    "defaultHttpdHostname": "seed.radicle.garden",
    "defaultHttpdScheme": "https",
    "defaultNodePort": 8776,
@@ -18,7 +17,6 @@
    ]
  },
  "supportWebsite": "https://radicle.zulipchat.com",
-
  "reactions": ["👍", "👎", "😄", "🎉", "🙁", "🚀", "👀"],
  "fallbackPreferredSeed": {
    "hostname": "seed.radicle.garden",
    "port": 443,
modified config/test.json
@@ -1,7 +1,6 @@
{
  "nodes": {
    "defaultHttpdPort": 8081,
-
    "defaultLocalHttpdPort": 8081,
    "defaultHttpdHostname": "127.0.0.1",
    "defaultHttpdScheme": "http",
    "pinned": [
modified http-client/index.ts
@@ -9,6 +9,7 @@ import type {
  TreeStats,
} from "./lib/project.js";
import type {
+
  Config,
  SuccessResponse,
  CodeLocation,
  Range,
@@ -65,6 +66,7 @@ export type {
  Commit,
  CommitBlob,
  CommitHeader,
+
  Config,
  DefaultSeedingPolicy,
  Diff,
  DiffBlob,
modified http-client/lib/profile.ts
@@ -1,16 +1,11 @@
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type { z } from "zod";

-
import { array, boolean, object, string } from "zod";
-
import { nodeConfigSchema } from "./shared.js";
+
import { object, string } from "zod";
+
import { configSchema } from "./shared.js";

const profileSchema = object({
-
  config: object({
-
    publicExplorer: string(),
-
    preferredSeeds: array(string()),
-
    cli: object({ hints: boolean() }),
-
    node: nodeConfigSchema,
-
  }),
+
  config: configSchema,
  home: string(),
});

modified http-client/lib/shared.ts
@@ -1,6 +1,6 @@
import type { ZodSchema, z } from "zod";

-
import { array, literal, number, object, string, union } from "zod";
+
import { array, boolean, literal, number, object, string, union } from "zod";

export interface SuccessResponse {
  success: true;
@@ -87,6 +87,23 @@ export const nodeConfigSchema = object({

export type DefaultSeedingPolicy = z.infer<typeof defaultSeedingPolicySchema>;

+
export const configSchema = object({
+
  publicExplorer: string(),
+
  preferredSeeds: array(string()),
+
  cli: object({ hints: boolean() }),
+
  web: object({
+
    pinned: object({
+
      repositories: array(string()),
+
    }),
+
    imageUrl: string().optional(),
+
    name: string().optional(),
+
    description: string().optional(),
+
  }),
+
  node: nodeConfigSchema,
+
});
+

+
export type Config = z.infer<typeof configSchema>;
+

export const rangeSchema = union([
  object({
    type: literal("lines"),
modified radicle-httpd/Cargo.lock
@@ -210,18 +210,6 @@ dependencies = [
]

[[package]]
-
name = "axum-auth"
-
version = "0.7.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a"
-
dependencies = [
-
 "async-trait",
-
 "axum-core",
-
 "base64 0.21.7",
-
 "http",
-
]
-

-
[[package]]
name = "axum-core"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1720,11 +1708,10 @@ dependencies = [

[[package]]
name = "radicle-httpd"
-
version = "0.12.1"
+
version = "0.13.0"
dependencies = [
 "anyhow",
 "axum",
-
 "axum-auth",
 "axum-server",
 "base64 0.21.7",
 "chrono",
modified radicle-httpd/Cargo.toml
@@ -3,7 +3,7 @@ name = "radicle-httpd"
description = "Radicle HTTP daemon"
homepage = "https://radicle.xyz"
license = "MIT OR Apache-2.0"
-
version = "0.12.1"
+
version = "0.13.0"
authors = ["cloudhead <cloudhead@radicle.xyz>"]
edition = "2021"
default-run = "radicle-httpd"
@@ -23,7 +23,6 @@ path = "src/main.rs"
[dependencies]
anyhow = { version = "1" }
axum = { version = "0.7.2", default-features = false, features = ["json", "query", "tokio", "http1"] }
-
axum-auth = { version= "0.7.0", default-features = false, features = ["auth-bearer"] }
axum-server = { version = "0.6.0", default-features = false }
base64 = "0.21.3"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
modified radicle-httpd/src/api.rs
@@ -1,6 +1,3 @@
-
pub mod auth;
-

-
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

@@ -14,17 +11,15 @@ use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
use serde_json::json;
-
use tokio::sync::RwLock;
use tower_http::cors::{self, CorsLayer};

use radicle::cob::{issue, patch, Author};
use radicle::identity::{DocAt, RepoId};
use radicle::node::policy::Scope;
use radicle::node::routing::Store;
-
use radicle::node::AliasStore;
-
use radicle::node::{Handle, NodeId};
+
use radicle::node::{AliasStore, NodeId};
use radicle::storage::{ReadRepository, ReadStorage};
-
use radicle::{Node, Profile};
+
use radicle::Profile;

mod error;
mod json;
@@ -36,15 +31,11 @@ use crate::Options;

pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
-
pub const API_VERSION: &str = "1.2.0";
-

-
/// Identifier for sessions
-
type SessionId = String;
+
pub const API_VERSION: &str = "2.0.0";

#[derive(Clone)]
pub struct Context {
    profile: Arc<Profile>,
-
    sessions: Arc<RwLock<HashMap<SessionId, auth::Session>>>,
    cache: Option<Cache>,
}

@@ -52,7 +43,6 @@ impl Context {
    pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
        Self {
            profile,
-
            sessions: Default::default(),
            cache: options.cache.map(Cache::new),
        }
    }
@@ -106,11 +96,6 @@ impl Context {
    pub fn profile(&self) -> &Arc<Profile> {
        &self.profile
    }
-

-
    #[cfg(test)]
-
    pub fn sessions(&self) -> &Arc<RwLock<HashMap<SessionId, auth::Session>>> {
-
        &self.sessions
-
    }
}

pub fn router(ctx: Context) -> Router {
@@ -342,12 +327,3 @@ mod project {
        pub seeding: usize,
    }
}
-

-
/// Announce refs to the network for the given RID.
-
pub fn announce_refs(mut node: Node, rid: RepoId) -> Result<(), Error> {
-
    match node.announce_refs(rid) {
-
        Ok(_) => Ok(()),
-
        Err(e) if e.is_connection_err() => Ok(()),
-
        Err(e) => Err(e.into()),
-
    }
-
}
deleted radicle-httpd/src/api/auth.rs
@@ -1,44 +0,0 @@
-
use serde::{Deserialize, Serialize};
-
use time::serde::timestamp;
-
use time::{Duration, OffsetDateTime};
-

-
use radicle::crypto::PublicKey;
-
use radicle::node::Alias;
-

-
use crate::api::error::Error;
-
use crate::api::Context;
-

-
pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::seconds(60);
-
pub const AUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::weeks(1);
-

-
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
-
#[serde(rename_all = "lowercase")]
-
pub enum AuthState {
-
    Authorized,
-
    Unauthorized,
-
}
-

-
#[derive(Clone, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
pub struct Session {
-
    pub status: AuthState,
-
    pub public_key: PublicKey,
-
    pub alias: Alias,
-
    #[serde(with = "timestamp")]
-
    pub issued_at: OffsetDateTime,
-
    #[serde(with = "timestamp")]
-
    pub expires_at: OffsetDateTime,
-
}
-

-
pub async fn validate(ctx: &Context, token: &str) -> Result<(), Error> {
-
    let sessions_store = ctx.sessions.read().await;
-
    let session = sessions_store
-
        .get(token)
-
        .ok_or(Error::Auth("Unauthorized"))?;
-

-
    if session.status != AuthState::Authorized || session.expires_at <= OffsetDateTime::now_utc() {
-
        return Err(Error::Auth("Unauthorized"));
-
    }
-

-
    Ok(())
-
}
modified radicle-httpd/src/api/error.rs
@@ -10,10 +10,6 @@ pub enum Error {
    #[error("entity not found")]
    NotFound,

-
    /// An error occurred during an authentication process.
-
    #[error("could not authenticate: {0}")]
-
    Auth(&'static str),
-

    /// An error occurred with env variables.
    #[error(transparent)]
    Env(#[from] std::env::VarError),
@@ -97,10 +93,6 @@ pub enum Error {
    /// Node error.
    #[error(transparent)]
    Node(#[from] radicle::node::Error),
-

-
    /// Invalid update to issue or patch.
-
    #[error("{0}")]
-
    BadRequest(String),
}

impl IntoResponse for Error {
@@ -111,7 +103,6 @@ impl IntoResponse for Error {
            Error::CobStore(e @ radicle::cob::store::Error::NotFound(_, _)) => {
                (StatusCode::NOT_FOUND, Some(e.to_string()))
            }
-
            Error::Auth(msg) => (StatusCode::UNAUTHORIZED, Some(msg.to_string())),
            Error::Crypto(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())),
            Error::Surf(radicle_surf::Error::Git(e)) if radicle::git::is_not_found_err(&e) => {
                (StatusCode::NOT_FOUND, Some(e.message().to_owned()))
@@ -135,7 +126,6 @@ impl IntoResponse for Error {
            Error::StorageRef(err) if err.is_not_found() => {
                (StatusCode::NOT_FOUND, Some(err.to_string()))
            }
-
            Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
            other => {
                tracing::error!("Error: {message}");
                tracing::debug!("Error Debug: {:?}", other);
modified radicle-httpd/src/api/json.rs
@@ -21,8 +21,6 @@ use radicle_surf::blob::Blob;
use radicle_surf::tree::{EntryKind, Tree};
use radicle_surf::{Commit, Oid};

-
use crate::api::auth::Session;
-

/// Returns JSON of a commit.
pub(crate) fn commit(commit: &Commit) -> Value {
    json!({
@@ -42,18 +40,6 @@ pub(crate) fn commit(commit: &Commit) -> Value {
    })
}

-
/// Returns JSON of a session.
-
pub(crate) fn session(session_id: String, session: &Session) -> Value {
-
    json!({
-
      "sessionId": session_id,
-
      "status": session.status,
-
      "publicKey": session.public_key,
-
      "alias": session.alias,
-
      "issuedAt": session.issued_at.unix_timestamp(),
-
      "expiresAt": session.expires_at.unix_timestamp()
-
    })
-
}
-

/// Returns JSON for a blob with a given `path`.
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
    json!({
modified radicle-httpd/src/api/v1.rs
@@ -2,7 +2,6 @@ mod delegates;
mod node;
mod profile;
mod projects;
-
mod sessions;
mod stats;

use axum::extract::State;
@@ -22,7 +21,6 @@ pub fn router(ctx: Context) -> Router {
        .merge(root_router)
        .merge(node::router(ctx.clone()))
        .merge(profile::router(ctx.clone()))
-
        .merge(sessions::router(ctx.clone()))
        .merge(delegates::router(ctx.clone()))
        .merge(projects::router(ctx.clone()))
        .merge(stats::router(ctx));
modified radicle-httpd/src/api/v1/node.rs
@@ -1,31 +1,24 @@
use axum::extract::State;
use axum::response::IntoResponse;
-
use axum::routing::{get, put};
+
use axum::routing::get;
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
-
use hyper::StatusCode;
use serde_json::json;

use radicle::identity::RepoId;
use radicle::node::address::Store as AddressStore;
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, Handle, NodeId, DEFAULT_TIMEOUT};
+
use radicle::node::{AliasStore, Handle, NodeId};
use radicle::Node;

use crate::api::error::Error;
-
use crate::api::{self, Context, PoliciesQuery};
-
use crate::axum_extra::{Path, Query};
+
use crate::api::Context;
+
use crate::axum_extra::Path;

pub fn router(ctx: Context) -> Router {
    Router::new()
        .route("/node", get(node_handler))
        .route("/node/policies/repos", get(node_policies_repos_handler))
-
        .route(
-
            "/node/policies/repos/:rid",
-
            put(node_policies_seed_handler)
-
                .delete(node_policies_unseed_handler)
-
                .get(node_policies_repo_handler),
-
        )
+
        .route("/node/policies/repos/:rid", get(node_policies_repo_handler))
        .route("/nodes/:nid", get(nodes_handler))
        .route("/nodes/:nid/inventory", get(nodes_inventory_handler))
        .with_state(ctx)
@@ -106,42 +99,6 @@ async fn node_policies_repo_handler(
    Ok::<_, Error>(Json(*policy))
}

-
/// Seed a new repo.
-
/// `PUT /node/policies/repos/:rid`
-
async fn node_policies_seed_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Query(qs): Query<PoliciesQuery>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-
    let mut node = Node::new(ctx.profile.socket());
-
    node.seed(project, qs.scope.unwrap_or_default())?;
-

-
    if let Some(from) = qs.from {
-
        let results = node.fetch(project, from, DEFAULT_TIMEOUT)?;
-
        return Ok::<_, Error>((
-
            StatusCode::OK,
-
            Json(json!({ "success": true, "results": results })),
-
        ));
-
    }
-
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
-
}
-

-
/// Unseed a repo.
-
/// `DELETE /node/policies/repos/:rid`
-
async fn node_policies_unseed_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-
    let mut node = Node::new(ctx.profile.socket());
-
    node.unseed(project)?;
-

-
    Ok::<_, Error>((StatusCode::OK, Json(json!({ "success": true }))))
-
}
-

#[cfg(test)]
mod routes {
    use std::net::SocketAddr;
modified radicle-httpd/src/api/v1/profile.rs
@@ -1,6 +1,4 @@
-
use std::net::SocketAddr;
-

-
use axum::extract::{ConnectInfo, State};
+
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
@@ -17,14 +15,7 @@ pub fn router(ctx: Context) -> Router {

/// Return local profile information.
/// `GET /profile`
-
async fn profile_handler(
-
    State(ctx): State<Context>,
-
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
-
) -> impl IntoResponse {
-
    if !addr.ip().is_loopback() {
-
        return Err(Error::Auth("Profile data is only shown for localhost"));
-
    }
-

+
async fn profile_handler(State(ctx): State<Context>) -> impl IntoResponse {
    Ok::<_, Error>(Json(
        json!({ "config": ctx.profile.config, "home": ctx.profile.home.path() }),
    ))
modified radicle-httpd/src/api/v1/projects.rs
@@ -3,28 +3,24 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use axum::extract::{DefaultBodyLimit, State};
use axum::http::header;
use axum::response::IntoResponse;
-
use axum::routing::{get, patch, post};
+
use axum::routing::get;
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
use hyper::StatusCode;
use radicle_surf::blob::BlobRef;
use radicle_surf::{diff, Glob, Oid, Repository};
use serde::{Deserialize, Serialize};
use serde_json::json;

-
use radicle::cob::{
-
    issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Author,
-
    Embed, Label, Uri,
-
};
-
use radicle::identity::{Did, RepoId};
+
use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _, Author};
+
use radicle::identity::RepoId;
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, Node, NodeId};
-
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
+
use radicle::node::{AliasStore, NodeId};
+
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository};

use crate::api::error::Error;
use crate::api::project::Info;
use crate::api::search::{SearchQueryString, SearchResult};
-
use crate::api::{self, announce_refs, CobsQuery, Context, PaginationQuery, ProjectQuery};
+
use crate::api::{self, CobsQuery, Context, PaginationQuery, ProjectQuery};
use crate::axum_extra::{cached_response, immutable_response, Path, Query};

const MAX_BODY_LIMIT: usize = 4_194_304;
@@ -48,22 +44,10 @@ pub fn router(ctx: Context) -> Router {
        .route("/projects/:project/remotes/:peer", get(remote_handler))
        .route("/projects/:project/blob/:sha/*path", get(blob_handler))
        .route("/projects/:project/readme/:sha", get(readme_handler))
-
        .route(
-
            "/projects/:project/issues",
-
            post(issue_create_handler).get(issues_handler),
-
        )
-
        .route(
-
            "/projects/:project/issues/:id",
-
            patch(issue_update_handler).get(issue_handler),
-
        )
-
        .route(
-
            "/projects/:project/patches",
-
            post(patch_create_handler).get(patches_handler),
-
        )
-
        .route(
-
            "/projects/:project/patches/:id",
-
            patch(patch_update_handler).get(patch_handler),
-
        )
+
        .route("/projects/:project/issues", get(issues_handler))
+
        .route("/projects/:project/issues/:id", get(issue_handler))
+
        .route("/projects/:project/patches", get(patches_handler))
+
        .route("/projects/:project/patches/:id", get(patch_handler))
        .with_state(ctx)
        .layer(DefaultBodyLimit::max(MAX_BODY_LIMIT))
}
@@ -654,113 +638,6 @@ async fn issues_handler(
    Ok::<_, Error>(Json(issues))
}

-
#[derive(Debug, Deserialize, Serialize)]
-
pub struct IssueCreate {
-
    pub title: String,
-
    pub description: String,
-
    pub labels: Vec<Label>,
-
    pub assignees: Vec<Did>,
-
    pub embeds: Vec<Embed<Uri>>,
-
}
-

-
/// Create a new issue.
-
/// `POST /projects/:project/issues`
-
async fn issue_create_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Json(issue): Json<IssueCreate>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let (repo, _) = ctx.repo(project)?;
-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let embeds: Vec<Embed> = issue
-
        .embeds
-
        .into_iter()
-
        .filter_map(|embed| resolve_embed(&repo, embed))
-
        .collect();
-

-
    let mut issues = ctx.profile.issues_mut(&repo)?;
-
    let issue = issues
-
        .create(
-
            issue.title,
-
            issue.description,
-
            &issue.labels,
-
            &issue.assignees,
-
            embeds,
-
            &signer,
-
        )
-
        .map_err(Error::from)?;
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json!({ "success": true, "id": issue.id().to_string() })),
-
    ))
-
}
-

-
/// Update an issue.
-
/// `PATCH /projects/:project/issues/:id`
-
async fn issue_update_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path((project, issue_id)): Path<(RepoId, Oid)>,
-
    Json(action): Json<issue::Action>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let (repo, _) = ctx.repo(project)?;
-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx.profile.signer()?;
-
    let mut issues = ctx.profile.issues_mut(&repo)?;
-
    let mut issue = issues.get_mut(&issue_id.into())?;
-

-
    let id = match action {
-
        issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
-
        issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
-
        issue::Action::Label { labels } => issue.label(labels, &signer)?,
-
        issue::Action::Edit { title } => issue.edit(title, &signer)?,
-
        issue::Action::Comment {
-
            body,
-
            reply_to,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            if let Some(to) = reply_to {
-
                issue.comment(body, to, embeds, &signer)?
-
            } else {
-
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
-
            }
-
        }
-
        issue::Action::CommentReact {
-
            id,
-
            reaction,
-
            active,
-
        } => issue.react(id, reaction, active, &signer)?,
-
        issue::Action::CommentEdit { id, body, embeds } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            issue.edit_comment(id, body, embeds, &signer)?
-
        }
-
        issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
-
    };
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
-
}
-

/// Get project issue.
/// `GET /projects/:project/issues/:id`
async fn issue_handler(
@@ -778,198 +655,6 @@ async fn issue_handler(
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
}

-
#[derive(Deserialize, Serialize)]
-
pub struct PatchCreate {
-
    pub title: String,
-
    pub description: String,
-
    pub target: Oid,
-
    pub oid: Oid,
-
    pub labels: Vec<Label>,
-
}
-

-
/// Create a new patch.
-
/// `POST /projects/:project/patches`
-
async fn patch_create_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(project): Path<RepoId>,
-
    Json(patch): Json<PatchCreate>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let (repo, _) = ctx.repo(project)?;
-
    let mut patches = ctx.profile.patches_mut(&repo)?;
-
    let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?;
-

-
    let patch = patches
-
        .create(
-
            patch.title,
-
            patch.description,
-
            patch::MergeTarget::default(),
-
            base_oid,
-
            patch.oid,
-
            &patch.labels,
-
            &signer,
-
        )
-
        .map_err(Error::from)?;
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json!({ "success": true, "id": patch.id.to_string() })),
-
    ))
-
}
-

-
/// Update an patch.
-
/// `PATCH /projects/:project/patches/:id`
-
async fn patch_update_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path((project, patch_id)): Path<(RepoId, Oid)>,
-
    Json(action): Json<patch::Action>,
-
) -> impl IntoResponse {
-
    api::auth::validate(&ctx, &token).await?;
-

-
    let node = Node::new(ctx.profile.socket());
-
    let signer = ctx
-
        .profile
-
        .signer()
-
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let (repo, _) = ctx.repo(project)?;
-
    let mut patches = ctx.profile.patches_mut(&repo)?;
-
    let mut patch = patches.get_mut(&patch_id.into())?;
-
    let id = match action {
-
        patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
-
        patch::Action::Label { labels } => patch.label(labels, &signer)?,
-
        patch::Action::Lifecycle { state } => patch.lifecycle(state, &signer)?,
-
        patch::Action::Assign { assignees } => patch.assign(assignees, &signer)?,
-
        patch::Action::Merge { revision, commit } => {
-
            // TODO: We should cleanup the stored copy at least.
-
            patch.merge(revision, commit, &signer)?.entry
-
        }
-
        patch::Action::Review {
-
            revision,
-
            summary,
-
            verdict,
-
            labels,
-
        } => *patch.review(revision, verdict, summary, labels, &signer)?,
-
        patch::Action::ReviewEdit {
-
            review,
-
            summary,
-
            verdict,
-
        } => patch.edit_review(review, summary, verdict, &signer)?,
-
        patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
-
        patch::Action::ReviewComment {
-
            review,
-
            body,
-
            reply_to,
-
            location,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?
-
        }
-
        patch::Action::ReviewCommentEdit {
-
            review,
-
            comment,
-
            body,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.edit_review_comment(review, comment, body, embeds, &signer)?
-
        }
-
        patch::Action::ReviewCommentReact {
-
            review,
-
            comment,
-
            reaction,
-
            active,
-
        } => patch.react_review_comment(review, comment, reaction, active, &signer)?,
-
        patch::Action::ReviewCommentRedact { review, comment } => {
-
            patch.redact_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::ReviewCommentResolve { review, comment } => {
-
            patch.resolve_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::ReviewCommentUnresolve { review, comment } => {
-
            patch.unresolve_review_comment(review, comment, &signer)?
-
        }
-
        patch::Action::Revision {
-
            description,
-
            base,
-
            oid,
-
            ..
-
        } => patch.update(description, base, oid, &signer)?.into(),
-
        patch::Action::RevisionEdit {
-
            revision,
-
            description,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.edit_revision(revision, description, embeds, &signer)?
-
        }
-
        patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
-
        patch::Action::RevisionReact {
-
            revision,
-
            reaction,
-
            active,
-
            location,
-
        } => patch.react(revision, reaction, location, active, &signer)?,
-
        patch::Action::RevisionComment {
-
            revision,
-
            body,
-
            reply_to,
-
            location,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.comment(revision, body, reply_to, location, embeds, &signer)?
-
        }
-
        patch::Action::RevisionCommentEdit {
-
            revision,
-
            comment,
-
            body,
-
            embeds,
-
        } => {
-
            let embeds: Vec<Embed> = embeds
-
                .into_iter()
-
                .filter_map(|embed| resolve_embed(&repo, embed))
-
                .collect();
-
            patch.comment_edit(revision, comment, body, embeds, &signer)?
-
        }
-
        patch::Action::RevisionCommentReact {
-
            revision,
-
            comment,
-
            reaction,
-
            active,
-
        } => patch.comment_react(revision, comment, reaction, active, &signer)?,
-
        patch::Action::RevisionCommentRedact { revision, comment } => {
-
            patch.comment_redact(revision, comment, &signer)?
-
        }
-
    };
-

-
    announce_refs(node, repo.id())?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
-
}
-

/// Get project patches list.
/// `GET /projects/:project/patches`
async fn patches_handler(
@@ -1029,7 +714,6 @@ async fn patch_handler(
mod routes {
    use std::net::SocketAddr;

-
    use axum::body::Body;
    use axum::extract::connect_info::MockConnectInfo;
    use axum::http::StatusCode;
    use pretty_assertions::assert_eq;
@@ -1952,1736 +1636,6 @@ mod routes {
    }

    #[tokio::test]
-
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "fcd0d5940b55df596cf8079fd1845903f1104bcd";
-

-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
            "title": "Issue #2",
-
            "description": "Change 'hello world' to 'hello everyone'",
-
            "labels": ["bug"],
-
            "embeds": [
-
              {
-
                "name": "example.html",
-
                "content": "data:image/png;base64,PGh0bWw+SGVsbG8gV29ybGQhPC9odG1sPg=="
-
              }
-
            ],
-
            "assignees": [],
-
        }))
-
        .unwrap();
-

-
        let response = post(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::CREATED);
-
        assert_eq!(
-
            response.json().await,
-
            json!({ "success": true, "id": CREATED_ISSUE_ID })
-
        );
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{CREATED_ISSUE_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CREATED_ISSUE_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #2",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [{
-
                "id": CREATED_ISSUE_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "body": "Change 'hello world' to 'hello everyone'",
-
                "edits": [
-
                  {
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "body": "Change 'hello world' to 'hello everyone'",
-
                    "timestamp": TIMESTAMP,
-
                    "embeds": [
-
                      {
-
                        "name": "example.html",
-
                        "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84",
-
                      },
-
                    ],
-
                  },
-
                ],
-
                "embeds": [
-
                  {
-
                    "name": "example.html",
-
                    "content": "git:b62df2ec90365e3749cd4fa431cb844492908b84"
-
                  }
-
                ],
-
                "reactions": [],
-
                "timestamp": TIMESTAMP,
-
                "replyTo": null,
-
                "resolved": false,
-
              }],
-
              "labels": [
-
                  "bug",
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_comment() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment",
-
          "body": "This is first-level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "replyTo": ISSUE_DISCUSSION_ID,
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        // Get ID to redact later in the test
-
        let response = response.json().await;
-
        let id = &response["id"];
-
        assert!(id.is_string());
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.react",
-
          "id": ISSUE_DISCUSSION_ID,
-
          "reaction": "🚀",
-
          "active": true,
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.edit",
-
          "id": ISSUE_DISCUSSION_ID,
-
          "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
          "embeds": [
-
            {
-
              "name":"image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc"
-
            }
-
          ]
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.success().await, true);
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment.redact",
-
          "id": id.as_str().unwrap(),
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.success().await, true);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "EDIT: Change 'hello world' to 'hello anyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        },
-
                      ],
-
                    },
-
                  ],
-
                  "embeds": [
-
                    {
-
                      "name": "image.jpg",
-
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                    }
-
                  ],
-
                  "reactions": [
-
                    {
-
                      "emoji": "🚀",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS,
-
                        }
-
                      ],
-
                    },
-
                  ],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_assign() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "assign",
-
          "assignees": [CONTRIBUTOR_DID],
-
        }))
-
        .unwrap();
-

-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS,
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [
-
                {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS,
-
                }
-
              ],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS,
-
                  },
-
                  "body": "Change 'hello world' to 'hello everyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS,
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "embeds": [],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_issues_reply() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "comment",
-
          "body": "This is a reply to the first comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "replyTo": ISSUE_DISCUSSION_ID,
-
        }))
-
        .unwrap();
-

-
        let _ = get(&app, format!("/projects/{CONTRIBUTOR_RID}/issues")).await;
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(response.success().await, true);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/issues/{ISSUE_DISCUSSION_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": ISSUE_DISCUSSION_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "Issue #1",
-
              "state": {
-
                "status": "open",
-
              },
-
              "assignees": [],
-
              "discussion": [
-
                {
-
                  "id": ISSUE_DISCUSSION_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "Change 'hello world' to 'hello everyone'",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Change 'hello world' to 'hello everyone'",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "embeds": [],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": null,
-
                  "resolved": false,
-
                },
-
                {
-
                  "id": ISSUE_COMMENT_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "body": "This is a reply to the first comment",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a reply to the first comment",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        },
-
                      ],
-
                    },
-
                  ],
-
                  "embeds": [
-
                    {
-
                      "name": "image.jpg",
-
                      "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                    }
-
                  ],
-
                  "reactions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "replyTo": ISSUE_DISCUSSION_ID,
-
                  "resolved": false,
-
                },
-
              ],
-
              "labels": [],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        let response = get(&app, format!("/projects/{CONTRIBUTOR_RID}/patches")).await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!([
-
              {
-
                "id": CONTRIBUTOR_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "A new `hello world`",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CONTRIBUTOR_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "change `hello world` in README to something else",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "change `hello world` in README to something else",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "base": PARENT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            ])
-
        );
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "id": CONTRIBUTOR_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "A new `hello world`",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CONTRIBUTOR_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "change `hello world` in README to something else",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "change `hello world` in README to something else",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                    ],
-
                    "base": PARENT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_create_patches() {
-
        const CREATED_PATCH_ID: &str = "9aabc4055fd811f915c55e9a6ea9f525aa3e88f2";
-

-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        create_session(ctx).await;
-

-
        let body = serde_json::to_vec(&json!({
-
          "title": "Update README",
-
          "description": "Do some changes to README",
-
          "target": INITIAL_COMMIT,
-
          "oid": HEAD,
-
          "labels": [],
-
        }))
-
        .unwrap();
-

-
        let response = post(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::CREATED);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "success": true,
-
                "id": CREATED_PATCH_ID,
-
              }
-
            )
-
        );
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CREATED_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(
-
            response.json().await,
-
            json!(
-
              {
-
                "id": CREATED_PATCH_ID,
-
                "author": {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS
-
                },
-
                "title": "Update README",
-
                "state": { "status": "open" },
-
                "target": "delegates",
-
                "labels": [],
-
                "merges": [],
-
                "assignees": [],
-
                "revisions": [
-
                  {
-
                    "id": CREATED_PATCH_ID,
-
                    "reactions": [],
-
                    "author": {
-
                      "id": CONTRIBUTOR_DID,
-
                      "alias": CONTRIBUTOR_ALIAS
-
                    },
-
                    "description": "Do some changes to README",
-
                    "edits": [
-
                      {
-
                        "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        },
-
                        "body": "Do some changes to README",
-
                        "timestamp": TIMESTAMP,
-
                        "embeds": [],
-
                      },
-
                   ],
-
                    "base": INITIAL_COMMIT,
-
                    "oid": HEAD,
-
                    "refs": [
-
                      "refs/heads/master",
-
                    ],
-
                    "discussions": [],
-
                    "timestamp": TIMESTAMP,
-
                    "reviews": [],
-
                  }
-
                ],
-
              }
-
            )
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_assign() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "assign",
-
          "assignees": [CONTRIBUTOR_DID]
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [
-
                {
-
                  "id": CONTRIBUTOR_DID,
-
                  "alias": CONTRIBUTOR_ALIAS,
-
                }
-
              ],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_label() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "label",
-
          "labels": ["bug","design"],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [
-
                "bug",
-
                "design"
-
              ],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_revisions() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision",
-
          "description": "This is a new revision",
-
          "base": PARENT,
-
          "oid": HEAD,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
                {
-
                  "id": "cccf3b0675220f25b054b6625d84611cb6506d9a",
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "This is a new revision",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a new revision",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                }
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_edit() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "edit",
-
          "title": "This is a updated title",
-
          "description": "Let's write some description",
-
          "target": "delegates",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "This is a updated title",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "reactions": [],
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_revisions_edit() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.edit",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "description": "Let's change the description a bit",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "reaction": "🚀",
-
          "location": {
-
            "commit": INITIAL_COMMIT,
-
            "path": "./README.md",
-
            "new": {
-
              "type": "lines",
-
              "range": {
-
                "start": 0,
-
                "end": 1
-
              }
-
            }
-
          },
-
          "active": true,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let body = serde_json::to_vec(&json!({
-
          "type": "revision.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "reaction": "🙏",
-
          "location": null,
-
          "active": true,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "Let's change the description a bit",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "Let's change the description a bit",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [
-
                    {
-
                      "emoji": "🙏",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        }
-
                      ],
-
                    },
-
                    {
-
                      "location": {
-
                        "commit": INITIAL_COMMIT,
-
                        "path": "./README.md",
-
                        "old": null,
-
                        "new": {
-
                          "type": "lines",
-
                          "range": {
-
                            "start": 0,
-
                            "end": 1
-
                          }
-
                        }
-
                      },
-
                      "emoji": "🚀",
-
                      "authors": [
-
                        {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                        }
-
                      ]
-
                    },
-
                  ],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_discussions() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "body": "This is a root level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let comment_id = response.id().await.to_string();
-
        let comment_react_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment.react",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": comment_id,
-
          "reaction": "🚀",
-
          "active": true
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(comment_react_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let comment_edit = serde_json::to_vec(&json!({
-
          "type": "revision.comment.edit",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": comment_id,
-
          "body": "EDIT: This is a root level comment",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
            }
-
          ],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(comment_edit)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        let reply_body = serde_json::to_vec(&json!({
-
          "type": "revision.comment",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "body": "This is a root level comment",
-
          "replyTo": comment_id,
-
          "embeds": [],
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(reply_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-
        let comment_id_2 = response.id().await.to_string();
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [
-
                    {
-
                      "id": comment_id,
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "EDIT: This is a root level comment",
-
                      "edits": [
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [
-
                            {
-
                                "name": "image.jpg",
-
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                        },
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "EDIT: This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [
-
                           {
-
                                "name": "image.jpg",
-
                                "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                        },
-
                      ],
-
                      "embeds": [
-
                        {
-
                          "name": "image.jpg",
-
                          "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                        }
-
                      ],
-
                      "reactions": [
-
                        {
-
                          "emoji": "🚀",
-
                          "authors": [
-
                            {
-
                              "id": CONTRIBUTOR_DID,
-
                              "alias": CONTRIBUTOR_ALIAS
-
                            }
-
                          ],
-
                        },
-
                      ],
-
                      "timestamp": TIMESTAMP,
-
                      "replyTo": null,
-
                      "location": null,
-
                      "resolved": false,
-
                    },
-
                    {
-
                      "id": comment_id_2,
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "This is a root level comment",
-
                      "edits": [
-
                        {
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "This is a root level comment",
-
                          "timestamp": TIMESTAMP,
-
                          "embeds": [],
-
                        },
-
                      ],
-
                      "embeds": [],
-
                      "reactions": [],
-
                      "timestamp": TIMESTAMP,
-
                      "replyTo": comment_id,
-
                      "location": null,
-
                      "resolved": false,
-
                    },
-
                  ],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_reviews() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "review",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "summary": "A small review",
-
          "verdict": "accept",
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let review_id = response.id().await.to_string();
-
        let review_comment_body = serde_json::to_vec(&json!({
-
          "type": "review.comment",
-
          "review": review_id,
-
          "body": "This is a comment on a review",
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC"
-
            }
-
          ],
-
          "location": {
-
            "commit": HEAD,
-
            "path": "README.md",
-
            "new": {
-
              "type": "lines",
-
              "range": {
-
                "start": 2,
-
                "end": 4
-
              }
-
            }
-
          }
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_comment_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let comment_id = response.id().await.to_string();
-
        let review_comment_edit_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.edit",
-
          "review": review_id,
-
          "comment": comment_id,
-
          "embeds": [
-
            {
-
              "name": "image.jpg",
-
              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
            }
-
          ],
-
          "body": "EDIT: This is a comment on a review",
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_comment_edit_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let review_react_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.react",
-
          "review": review_id,
-
          "comment": comment_id,
-
          "reaction": "🚀",
-
          "active": true
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_react_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let review_resolve_body = serde_json::to_vec(&json!({
-
          "type": "review.comment.resolve",
-
          "review": review_id,
-
          "comment": comment_id,
-
        }))
-
        .unwrap();
-
        patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(review_resolve_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": { "status": "open" },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [
-
                    {
-
                      "id": "140a44a4eac2cdb74b2f5f95a9dce97847eb9636",
-
                      "author": {
-
                          "id": CONTRIBUTOR_DID,
-
                          "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "verdict": "accept",
-
                      "summary": "A small review",
-
                      "comments": [
-
                        {
-
                          "id": "0dcfca53416761cf975cc4cd6d452790cee06b49",
-
                          "author": {
-
                            "id": CONTRIBUTOR_DID,
-
                            "alias": CONTRIBUTOR_ALIAS
-
                          },
-
                          "body": "EDIT: This is a comment on a review",
-
                          "edits": [
-
                            {
-
                              "author": {
-
                                "id": CONTRIBUTOR_DID,
-
                                "alias": CONTRIBUTOR_ALIAS
-
                              },
-
                              "body": "This is a comment on a review",
-
                              "timestamp": 1671125284,
-
                              "embeds": [
-
                                {
-
                                  "name": "image.jpg",
-
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                                },
-
                              ],
-
                            },
-
                            {
-
                              "author": {
-
                                "id": CONTRIBUTOR_DID,
-
                                "alias": CONTRIBUTOR_ALIAS
-
                              },
-
                              "body": "EDIT: This is a comment on a review",
-
                              "timestamp": 1671125284,
-
                              "embeds": [
-
                                {
-
                                  "name": "image.jpg",
-
                                  "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                                },
-
                              ],
-
                            },
-
                          ],
-
                          "embeds": [
-
                            {
-
                              "name": "image.jpg",
-
                              "content": "git:94381b429d7f7fe87e1bade52d893ab348ae29cc",
-
                            },
-
                          ],
-
                          "reactions": [
-
                            {
-
                              "emoji": "🚀",
-
                              "authors": [
-
                                {
-
                                  "id": CONTRIBUTOR_DID,
-
                                  "alias": CONTRIBUTOR_ALIAS
-
                                }
-
                              ],
-
                            },
-
                          ],
-
                          "timestamp": 1671125284,
-
                          "replyTo": null,
-
                          "location": {
-
                            "commit": HEAD,
-
                            "path": "README.md",
-
                            "old": null,
-
                            "new": {
-
                              "type": "lines",
-
                              "range": {
-
                                "start": 2,
-
                                "end": 4,
-
                              },
-
                            },
-
                          },
-
                          "resolved": true,
-
                        }
-
                      ],
-
                      "timestamp": 1671125284,
-
                    },
-
                  ],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
-
    async fn test_projects_patches_merges() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = contributor(tmp.path());
-
        let app = super::router(ctx.to_owned());
-
        create_session(ctx).await;
-
        let thread_body = serde_json::to_vec(&json!({
-
          "type": "merge",
-
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "commit": PARENT,
-
        }))
-
        .unwrap();
-
        let response = patch(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
            Some(Body::from(thread_body)),
-
            Some(SESSION_ID.to_string()),
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        let response = get(
-
            &app,
-
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
-
        )
-
        .await;
-

-
        assert_eq!(
-
            response.json().await,
-
            json!({
-
              "id": CONTRIBUTOR_PATCH_ID,
-
              "author": {
-
                "id": CONTRIBUTOR_DID,
-
                "alias": CONTRIBUTOR_ALIAS
-
              },
-
              "title": "A new `hello world`",
-
              "state": {
-
                  "status": "merged",
-
                  "revision": CONTRIBUTOR_PATCH_ID,
-
                  "commit": PARENT,
-
              },
-
              "target": "delegates",
-
              "labels": [],
-
              "merges": [{
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "commit": PARENT,
-
                  "timestamp": TIMESTAMP,
-
                  "revision": CONTRIBUTOR_PATCH_ID,
-
              }],
-
              "assignees": [],
-
              "revisions": [
-
                {
-
                  "id": CONTRIBUTOR_PATCH_ID,
-
                  "author": {
-
                    "id": CONTRIBUTOR_DID,
-
                    "alias": CONTRIBUTOR_ALIAS
-
                  },
-
                  "description": "change `hello world` in README to something else",
-
                  "edits": [
-
                    {
-
                      "author": {
-
                        "id": CONTRIBUTOR_DID,
-
                        "alias": CONTRIBUTOR_ALIAS
-
                      },
-
                      "body": "change `hello world` in README to something else",
-
                      "timestamp": TIMESTAMP,
-
                      "embeds": [],
-
                    },
-
                  ],
-
                  "reactions": [],
-
                  "base": PARENT,
-
                  "oid": HEAD,
-
                  "refs": [
-
                    "refs/heads/master",
-
                  ],
-
                  "discussions": [],
-
                  "timestamp": TIMESTAMP,
-
                  "reviews": [],
-
                },
-
              ],
-
            })
-
        );
-
    }
-

-
    #[tokio::test]
    async fn test_projects_private() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = seed(tmp.path());
deleted radicle-httpd/src/api/v1/sessions.rs
@@ -1,201 +0,0 @@
-
use std::iter::repeat_with;
-

-
use axum::extract::State;
-
use axum::response::IntoResponse;
-
use axum::routing::{post, put};
-
use axum::{Json, Router};
-
use axum_auth::AuthBearer;
-
use hyper::StatusCode;
-
use radicle::crypto::{PublicKey, Signature};
-
use serde::{Deserialize, Serialize};
-
use time::OffsetDateTime;
-

-
use crate::api::auth::{self, AuthState, Session};
-
use crate::api::error::Error;
-
use crate::api::json;
-
use crate::api::Context;
-
use crate::axum_extra::Path;
-

-
pub fn router(ctx: Context) -> Router {
-
    Router::new()
-
        .route("/sessions", post(session_create_handler))
-
        .route(
-
            "/sessions/:id",
-
            put(session_signin_handler)
-
                .get(session_handler)
-
                .delete(session_delete_handler),
-
        )
-
        .with_state(ctx)
-
}
-

-
#[derive(Debug, Deserialize, Serialize)]
-
struct AuthChallenge {
-
    sig: Signature,
-
    pk: PublicKey,
-
}
-

-
/// Create session.
-
/// `POST /sessions`
-
async fn session_create_handler(State(ctx): State<Context>) -> impl IntoResponse {
-
    let mut rng = fastrand::Rng::new();
-
    let session_id = repeat_with(|| rng.alphanumeric())
-
        .take(32)
-
        .collect::<String>();
-
    let signer = ctx.profile.signer().map_err(Error::from)?;
-
    let session = Session {
-
        status: AuthState::Unauthorized,
-
        public_key: *signer.public_key(),
-
        alias: ctx.profile.config.node.alias.clone(),
-
        issued_at: OffsetDateTime::now_utc(),
-
        expires_at: OffsetDateTime::now_utc()
-
            .checked_add(auth::UNAUTHORIZED_SESSIONS_EXPIRATION)
-
            .unwrap(),
-
    };
-
    let mut sessions = ctx.sessions.write().await;
-
    sessions.insert(session_id.clone(), session.clone());
-

-
    Ok::<_, Error>((
-
        StatusCode::CREATED,
-
        Json(json::session(session_id, &session)),
-
    ))
-
}
-

-
/// Get a session.
-
/// `GET /sessions/:id`
-
async fn session_handler(
-
    State(ctx): State<Context>,
-
    Path(session_id): Path<String>,
-
) -> impl IntoResponse {
-
    let sessions = ctx.sessions.read().await;
-
    let session = sessions.get(&session_id).ok_or(Error::NotFound)?;
-

-
    Ok::<_, Error>(Json(json::session(session_id, session)))
-
}
-

-
/// Update session.
-
/// `PUT /sessions/:id`
-
async fn session_signin_handler(
-
    State(ctx): State<Context>,
-
    Path(session_id): Path<String>,
-
    Json(request): Json<AuthChallenge>,
-
) -> impl IntoResponse {
-
    let mut sessions = ctx.sessions.write().await;
-
    let session = sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
-
    if session.status == AuthState::Unauthorized {
-
        if session.public_key != request.pk {
-
            return Err(Error::Auth("Invalid public key"));
-
        }
-
        if session.expires_at <= OffsetDateTime::now_utc() {
-
            return Err(Error::Auth("Session expired"));
-
        }
-
        let payload = format!("{}:{}", session_id, request.pk);
-
        request
-
            .pk
-
            .verify(payload.as_bytes(), &request.sig)
-
            .map_err(Error::from)?;
-
        session.status = AuthState::Authorized;
-
        session.expires_at = OffsetDateTime::now_utc()
-
            .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
-
            .unwrap();
-

-
        return Ok::<_, Error>(Json(json!({ "success": true })));
-
    }
-

-
    Err(Error::Auth("Session already authorized"))
-
}
-

-
/// Delete session.
-
/// `DELETE /sessions/:id`
-
async fn session_delete_handler(
-
    State(ctx): State<Context>,
-
    AuthBearer(token): AuthBearer,
-
    Path(session_id): Path<String>,
-
) -> impl IntoResponse {
-
    if token != session_id {
-
        return Err(Error::Auth("Not authorized to delete this session"));
-
    }
-
    let mut sessions = ctx.sessions.write().await;
-
    sessions.remove_entry(&token).ok_or(Error::NotFound)?;
-

-
    Ok::<_, Error>(Json(json!({ "success": true })))
-
}
-

-
#[cfg(test)]
-
mod routes {
-
    use axum::body::Body;
-
    use axum::http::StatusCode;
-
    use serde::{Deserialize, Serialize};
-

-
    use radicle::crypto::{PublicKey, Signature, Signer};
-

-
    use crate::api::auth::{AuthState, Session};
-
    use crate::test::{self, get, post, put};
-

-
    #[derive(Debug, Clone, Deserialize, Serialize)]
-
    #[serde(rename_all = "camelCase")]
-
    pub struct SessionInfo {
-
        pub session_id: String,
-
        pub public_key: PublicKey,
-
    }
-

-
    pub fn sign(
-
        signer: Box<dyn Signer>,
-
        session: &SessionInfo,
-
    ) -> Result<Signature, anyhow::Error> {
-
        signer
-
            .try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
-
            .map_err(anyhow::Error::from)
-
    }
-

-
    #[tokio::test]
-
    async fn test_session() {
-
        let tmp = tempfile::tempdir().unwrap();
-
        let ctx = test::seed(tmp.path());
-
        let app = super::router(ctx.to_owned());
-

-
        // Create session.
-
        let response = post(&app, "/sessions", None, None).await;
-
        let status = response.status();
-
        let json = response.json().await;
-
        let session_info: SessionInfo = serde_json::from_value(json).unwrap();
-

-
        assert_eq!(status, StatusCode::CREATED);
-

-
        // Check that an unauthorized session has been created.
-
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
-
        let status = response.status();
-
        let json = response.json().await;
-
        let body: Session = serde_json::from_value(json).unwrap();
-

-
        assert_eq!(status, StatusCode::OK);
-
        assert_eq!(body.status, AuthState::Unauthorized);
-

-
        // Create request body
-
        let signer = ctx.profile.signer().unwrap();
-
        let signature = sign(signer, &session_info).unwrap();
-
        let body = serde_json::to_vec(&super::AuthChallenge {
-
            sig: signature,
-
            pk: session_info.public_key,
-
        })
-
        .unwrap();
-

-
        let response = put(
-
            &app,
-
            format!("/sessions/{}", session_info.session_id),
-
            Some(Body::from(body)),
-
            None,
-
        )
-
        .await;
-

-
        assert_eq!(response.status(), StatusCode::OK);
-

-
        // Check that session has been authorized.
-
        let response = get(&app, format!("/sessions/{}", session_info.session_id)).await;
-
        let status = response.status();
-
        let json = response.json().await;
-
        let body: Session = serde_json::from_value(json).unwrap();
-

-
        assert_eq!(status, StatusCode::OK);
-
        assert_eq!(body.status, AuthState::Authorized);
-
    }
-
}
modified radicle-httpd/src/test.rs
@@ -8,11 +8,9 @@ use axum::body::{Body, Bytes};
use axum::http::{Method, Request};
use axum::Router;
use serde_json::Value;
-
use time::OffsetDateTime;
use tower::ServiceExt;

use radicle::cob::patch::MergeTarget;
-
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git::{raw as git2, RefString};
@@ -23,7 +21,7 @@ use radicle::Storage;
use radicle::{node, profile};
use radicle_crypto::test::signer::MockSigner;

-
use crate::api::{auth, Context};
+
use crate::api::Context;

pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
@@ -32,14 +30,8 @@ pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "ca67d195c0b308b51810dedd93157a20764d5db5";
-
pub const ISSUE_DISCUSSION_ID: &str = "41e2823caa54f1d53e375035ed4aabd0a89fa855";
-
pub const ISSUE_COMMENT_ID: &str = "e9f963fab82ad875e46b29a327c5d3d51f825cdc";
-
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
-
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
-
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_ALIAS: &str = "seed";
-
pub const CONTRIBUTOR_PATCH_ID: &str = "3e3f0dc34b3eeb64cfbc7218fbd52b97246e0564";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -81,17 +73,6 @@ pub fn seed(dir: &Path) -> Context {
    seed_with_signer(dir, profile, &signer)
}

-
pub fn contributor(dir: &Path) -> Context {
-
    let mut seed = [0xff; 32];
-
    *seed.last_mut().unwrap() = 0xee;
-

-
    let home = dir.join("radicle");
-
    let profile = profile(home.as_path(), seed);
-
    let signer = MemorySigner::load(&profile.keystore, None).unwrap();
-

-
    seed_with_signer(dir, profile, &signer)
-
}
-

fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G) -> Context {
    const DEFAULT_BRANCH: &str = "master";

@@ -319,24 +300,6 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    Context::new(Arc::new(profile), &options)
}

-
/// Adds an authorized session to the Context::sessions HashMap.
-
pub async fn create_session(ctx: Context) {
-
    let issued_at = OffsetDateTime::now_utc();
-
    let mut sessions = ctx.sessions().write().await;
-
    sessions.insert(
-
        String::from(SESSION_ID),
-
        auth::Session {
-
            status: auth::AuthState::Authorized,
-
            public_key: ctx.profile().public_key,
-
            alias: ctx.profile().config.node.alias.clone(),
-
            issued_at,
-
            expires_at: issued_at
-
                .checked_add(auth::AUTHORIZED_SESSIONS_EXPIRATION)
-
                .unwrap(),
-
        },
-
    );
-
}
-

pub async fn get(app: &Router, path: impl ToString) -> Response {
    Response(
        app.clone()
@@ -346,48 +309,6 @@ pub async fn get(app: &Router, path: impl ToString) -> Response {
    )
}

-
pub async fn post(
-
    app: &Router,
-
    path: impl ToString,
-
    body: Option<Body>,
-
    auth: Option<String>,
-
) -> Response {
-
    Response(
-
        app.clone()
-
            .oneshot(request(path, Method::POST, body, auth))
-
            .await
-
            .unwrap(),
-
    )
-
}
-

-
pub async fn patch(
-
    app: &Router,
-
    path: impl ToString,
-
    body: Option<Body>,
-
    auth: Option<String>,
-
) -> Response {
-
    Response(
-
        app.clone()
-
            .oneshot(request(path, Method::PATCH, body, auth))
-
            .await
-
            .unwrap(),
-
    )
-
}
-

-
pub async fn put(
-
    app: &Router,
-
    path: impl ToString,
-
    body: Option<Body>,
-
    auth: Option<String>,
-
) -> Response {
-
    Response(
-
        app.clone()
-
            .oneshot(request(path, Method::PUT, body, auth))
-
            .await
-
            .unwrap(),
-
    )
-
}
-

fn request(
    path: impl ToString,
    method: Method,
@@ -414,20 +335,6 @@ impl Response {
        serde_json::from_slice(&body).unwrap()
    }

-
    pub async fn id(self) -> radicle::git::Oid {
-
        let json = self.json().await;
-
        let string = json["id"].as_str().unwrap();
-

-
        radicle::git::Oid::from_str(string).unwrap()
-
    }
-

-
    pub async fn success(self) -> bool {
-
        let json = self.json().await;
-
        let success = json["success"].as_bool();
-

-
        success.unwrap_or(false)
-
    }
-

    pub fn status(&self) -> axum::http::StatusCode {
        self.0.status()
    }
modified src/App.svelte
@@ -2,7 +2,6 @@
  import Plausible from "plausible-tracker";

  import * as router from "@app/lib/router";
-
  import * as httpd from "@app/lib/httpd";
  import { unreachable } from "@app/lib/utils";

  import { codeFont, theme } from "@app/lib/appearance";
@@ -16,12 +15,10 @@
  import Home from "@app/views/home/Index.svelte";
  import Issue from "@app/views/projects/Issue.svelte";
  import Issues from "@app/views/projects/Issues.svelte";
-
  import NewIssue from "@app/views/projects/Issue/New.svelte";
  import Nodes from "@app/views/nodes/View.svelte";
  import NotFound from "@app/views/NotFound.svelte";
  import Patch from "@app/views/projects/Patch.svelte";
  import Patches from "@app/views/projects/Patches.svelte";
-
  import Session from "@app/views/session/Index.svelte";
  import Source from "@app/views/projects/Source.svelte";

  import Error from "@app/views/error/View.svelte";
@@ -29,7 +26,7 @@

  const activeRouteStore = router.activeRouteStore;

-
  void httpd.initialize().finally(() => void router.loadFromLocation());
+
  void router.loadFromLocation();

  if (import.meta.env.PROD) {
    const plausible = Plausible({ domain: "app.radicle.xyz" });
@@ -66,8 +63,6 @@
  <Home {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "nodes"}
  <Nodes {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "session"}
-
  <Session activeRoute={$activeRouteStore} />
{:else if $activeRouteStore.resource === "project.source"}
  <Source {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.history"}
@@ -76,8 +71,6 @@
  <Commit {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.issues"}
  <Issues {...$activeRouteStore.params} />
-
{:else if $activeRouteStore.resource === "project.newIssue"}
-
  <NewIssue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.issue"}
  <Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.patches"}
modified src/App/Header.svelte
@@ -1,23 +1,6 @@
<script lang="ts">
-
  import type { HttpdState } from "@app/lib/httpd";
-

-
  import { httpdStore } from "@app/lib/httpd";
-

-
  import Authenticate from "./Header/Authenticate.svelte";
  import Breadcrumbs from "./Header/Breadcrumbs.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
-
  import NodeInfo from "@app/App/Header/NodeInfo.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
-
  import { experimental } from "@app/lib/appearance";
-

-
  const buttonTitle: Record<HttpdState["state"], string> = {
-
    stopped: "radicle-httpd is stopped",
-
    running: "radicle-httpd is running",
-
    authenticated: "radicle-httpd is running - signed in",
-
  };
</script>

<style>
@@ -29,8 +12,7 @@
    padding: 0.5rem 1rem;
    height: 3.5rem;
  }
-
  .left,
-
  .right {
+
  .left {
    display: flex;
    align-items: center;
    gap: 0.5rem;
@@ -40,9 +22,6 @@
    height: var(--button-regular-height);
    margin: 0 0.5rem;
  }
-
  .connect-popover {
-
    max-width: 20rem;
-
  }
</style>

<header>
@@ -59,28 +38,4 @@
    </Link>
    <Breadcrumbs />
  </div>
-

-
  <div class="right">
-
    {#if $experimental}
-
      {#if $httpdStore.state === "stopped"}
-
        <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
          <Button
-
            slot="toggle"
-
            let:toggle
-
            on:click={toggle}
-
            title={buttonTitle[$httpdStore.state]}
-
            variant="naked-toggle">
-
            <IconSmall name="device" />
-
            Connect
-
          </Button>
-
          <div slot="popover" class="connect-popover">
-
            <ConnectInstructions />
-
          </div>
-
        </Popover>
-
      {:else}
-
        <NodeInfo node={$httpdStore.node} />
-
        <Authenticate />
-
      {/if}
-
    {/if}
-
  </div>
</header>
deleted src/App/Header/Authenticate.svelte
@@ -1,80 +0,0 @@
-
<script lang="ts">
-
  import * as httpd from "@app/lib/httpd";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { httpdStore } from "@app/lib/httpd";
-

-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import ConnectInstructions from "@app/components/ConnectInstructions.svelte";
-
</script>
-

-
<style>
-
  .container {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    width: 18rem;
-
  }
-
  .status {
-
    font-size: var(--font-size-tiny);
-
    color: var(--color-fill-gray);
-
    text-align: left;
-
  }
-
  .peer-info {
-
    display: flex;
-
    align-items: center;
-
    font-family: var(--font-family-monospace);
-
  }
-
  .user {
-
    display: flex;
-
    justify-content: space-between;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .connect-popover {
-
    max-width: 20rem;
-
  }
-
</style>
-

-
{#if $httpdStore.state === "authenticated"}
-
  <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
    <Button slot="toggle" let:toggle on:click={toggle} variant="naked-toggle">
-
      <div class="peer-info">
-
        <div style:height="1.25rem" style:margin-right="0.5rem">
-
          <Avatar nodeId={$httpdStore.session.publicKey} />
-
        </div>
-
        {$httpdStore.session.alias}
-
      </div>
-
    </Button>
-

-
    <div slot="popover" class="container">
-
      <div class="status">Authenticated as</div>
-
      <div class="user">
-
        <NodeId
-
          nodeId={$httpdStore.session.publicKey}
-
          alias={$httpdStore.session.alias} />
-
        <IconButton
-
          on:click={() => {
-
            void httpd.disconnect();
-
            closeFocused();
-
          }}>
-
          Disconnect
-
        </IconButton>
-
      </div>
-
    </div>
-
  </Popover>
-
{:else}
-
  <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
    <Button slot="toggle" let:toggle on:click={toggle} variant="naked-toggle">
-
      <IconSmall name="key" />
-
      Authenticate
-
    </Button>
-
    <div slot="popover" class="connect-popover">
-
      <ConnectInstructions />
-
    </div>
-
  </Popover>
-
{/if}
modified src/App/Header/Breadcrumbs.svelte
@@ -22,13 +22,13 @@
  }
</style>

-
{#if $activeRouteStore.resource === "booting" || $activeRouteStore.resource === "home" || $activeRouteStore.resource === "session" || $activeRouteStore.resource === "error" || $activeRouteStore.resource === "notFound"}
+
{#if $activeRouteStore.resource === "booting" || $activeRouteStore.resource === "home" || $activeRouteStore.resource === "error" || $activeRouteStore.resource === "notFound"}
  <!-- Don't render breadcrumbs for these routes. -->
{:else if $activeRouteStore.resource === "nodes"}
  <div class="breadcrumbs">
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} showLocalNode />
  </div>
-
{:else if $activeRouteStore.resource === "project.source" || $activeRouteStore.resource === "project.history" || $activeRouteStore.resource === "project.commit" || $activeRouteStore.resource === "project.issues" || $activeRouteStore.resource === "project.newIssue" || $activeRouteStore.resource === "project.issue" || $activeRouteStore.resource === "project.patches" || $activeRouteStore.resource === "project.patch"}
+
{:else if $activeRouteStore.resource === "project.source" || $activeRouteStore.resource === "project.history" || $activeRouteStore.resource === "project.commit" || $activeRouteStore.resource === "project.issues" || $activeRouteStore.resource === "project.issue" || $activeRouteStore.resource === "project.patches" || $activeRouteStore.resource === "project.patch"}
  <div class="breadcrumbs">
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} />

modified src/App/Header/Breadcrumbs/ProjectSegment.svelte
@@ -64,7 +64,7 @@
      }}>
      Commits
    </Link>
-
  {:else if activeRoute.resource === "project.newIssue" || activeRoute.resource === "project.issue" || activeRoute.resource === "project.issues"}
+
  {:else if activeRoute.resource === "project.issue" || activeRoute.resource === "project.issues"}
    <Separator />
    <Link
      route={{
deleted src/App/Header/NodeInfo.svelte
@@ -1,80 +0,0 @@
-
<script lang="ts">
-
  import type { HttpdNodeState } from "@app/lib/httpd";
-

-
  import { capitalize } from "lodash";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import ScopePolicyExplainer from "@app/components/ScopePolicyExplainer.svelte";
-

-
  export let node: HttpdNodeState;
-
</script>
-

-
<style>
-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
-
  .scope-policy {
-
    padding: 1rem 0;
-
    border-top: 1px solid var(--color-fill-separator);
-
    border-bottom: 1px solid var(--color-fill-separator);
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 1rem;
-
  }
-
</style>
-

-
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
  <Button slot="toggle" let:toggle on:click={toggle} variant={"naked-toggle"}>
-
    {#if node.state === "running"}
-
      <IconSmall name="online" />
-
      Online
-
    {:else}
-
      <IconSmall name="offline" />
-
      Offline
-
    {/if}
-
  </Button>
-

-
  <div slot="popover" style:width="18rem">
-
    {#if node.state === "running"}
-
      <div class="label">
-
        Your node is running and syncing with the network.
-
      </div>
-

-
      {#if node.seedingPolicy}
-
        <div class="scope-policy">
-
          <div style:display="flex">
-
            Seeding Policy: <span style:margin-left="auto" class="txt-semibold">
-
              {capitalize(node.seedingPolicy.default)}
-
            </span>
-
          </div>
-
          {#if node.seedingPolicy.default === "allow"}
-
            <div style:display="flex" style:margin-bottom="1rem">
-
              Scope:
-
              <span style:margin-left="auto" class="txt-semibold">
-
                {capitalize(node.seedingPolicy.scope)}
-
              </span>
-
            </div>
-
          {/if}
-

-
          <ScopePolicyExplainer seedingPolicy={node.seedingPolicy} />
-
        </div>
-
      {/if}
-
      <div class="label">
-
        Shut down your node if you want to stop sharing and receiving updates.
-
      </div>
-
      <Command command="rad node stop" fullWidth />
-
    {:else}
-
      <div class="label">Your node is not running.</div>
-
      <div class="label">
-
        Start your node to seed, clone or share your changes.
-
      </div>
-
      <Command command="rad node start" fullWidth />
-
    {/if}
-
  </div>
-
</Popover>
modified src/App/Settings.svelte
@@ -1,12 +1,8 @@
<script lang="ts">
-
  import { api, changeHttpdPort } from "@app/lib/httpd";
-
  import config from "virtual:config";
  import {
    codeFont,
    codeFonts,
-
    experimental,
    storeCodeFont,
-
    storeExperimental,
    storeTheme,
    theme,
  } from "@app/lib/appearance";
@@ -14,9 +10,6 @@
  import Icon from "@app/components/Icon.svelte";
  import Radio from "@app/components/Radio.svelte";
  import Button from "@app/components/Button.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  $: customPort = api.port;
</script>

<style>
@@ -84,40 +77,4 @@
      </Radio>
    </div>
  </div>
-
  <div class="item global-hide-on-mobile-down">
-
    <div
-
      style="display: flex; flex-direction: row; align-items: center; gap: 0.5rem;">
-
      Make changes on the web (experimental)
-
    </div>
-
    <div class="right">
-
      <Radio>
-
        <Button
-
          styleBorderRadius="0"
-
          on:click={() => storeExperimental(true)}
-
          variant={$experimental ? "selected" : "not-selected"}>
-
          On
-
        </Button>
-
        <div class="global-spacer" />
-
        <Radio>
-
          <Button
-
            styleBorderRadius="0"
-
            on:click={() => storeExperimental(undefined)}
-
            variant={$experimental ? "not-selected" : "selected"}>
-
            Off
-
          </Button>
-
        </Radio>
-
      </Radio>
-
    </div>
-
  </div>
-
  <div class="item global-hide-on-mobile-down">
-
    <div>Radicle HTTP Daemon Port</div>
-
    <div class="right txt-monospace" style:width="6rem">
-
      <TextInput
-
        name="httpd port"
-
        bind:value={customPort}
-
        placeholder={config.nodes.defaultLocalHttpdPort.toString()}
-
        valid={Number(customPort) >= 1 && Number(customPort) <= 65535}
-
        on:submit={() => changeHttpdPort(Number(customPort))} />
-
    </div>
-
  </div>
</div>
modified src/components/Comment.svelte
@@ -1,45 +1,24 @@
-
<script lang="ts" strictEvents>
-
  import type { Comment, Embed } from "@http-client";
+
<script lang="ts">
+
  import type { Comment } from "@http-client";

-
  import { tick } from "svelte";
-

-
  import { closeFocused } from "./Popover.svelte";
  import * as utils from "@app/lib/utils";

-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
  import Id from "@app/components/Id.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
-
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";

  export let id: string | undefined = undefined;
  export let authorId: string;
  export let authorAlias: string | undefined = undefined;
  export let body: string;
-
  export let enableAttachments: boolean = false;
  export let reactions: Comment["reactions"] | undefined = undefined;
-
  export let embeds: Map<string, Embed> | undefined = undefined;
  export let caption = "commented";
  export let rawPath: string;
  export let timestamp: number;
  export let isReply: boolean = false;
  export let isLastReply: boolean = false;
  export let lastEdit: Comment["edits"][0] | undefined = undefined;
-

-
  let state: "read" | "edit" | "submit" = "read";
-

-
  export let editComment:
-
    | ((body: string, embeds: Embed[]) => Promise<void>)
-
    | undefined = undefined;
-
  export let reactOnComment:
-
    | ((
-
        authors: Comment["reactions"][0]["authors"],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined = undefined;
</script>

<style>
@@ -78,11 +57,6 @@
    color: var(--color-fill-gray);
    font-size: var(--font-size-small);
  }
-
  .header-right {
-
    display: flex;
-
    margin-left: auto;
-
    gap: 0.5rem;
-
  }
  .card-body {
    display: flex;
    align-items: center;
@@ -109,10 +83,6 @@
  .card-header-no-icon {
    padding-left: 1rem;
  }
-
  .edit-buttons {
-
    display: flex;
-
    gap: 0.25rem;
-
  }
  .reply .card-body,
  .reply .actions {
    padding-left: 1rem;
@@ -155,69 +125,19 @@
          • edited
        </div>
      {/if}
-
      <div class="header-right">
-
        {#if id && editComment && state === "read"}
-
          <div class="edit-buttons global-hide-on-mobile-down">
-
            <IconButton title="edit comment" on:click={() => (state = "edit")}>
-
              <IconSmall name={"edit"} />
-
            </IconButton>
-
          </div>
-
        {/if}
-
      </div>
    </div>
  </div>

  {#if body}
    <div class="card-body">
-
      {#if editComment && state !== "read"}
-
        {@const editComment_ = editComment}
-
        <ExtendedTextarea
-
          {rawPath}
-
          {body}
-
          {embeds}
-
          {enableAttachments}
-
          submitInProgress={state === "submit"}
-
          submitCaption="Save"
-
          placeholder="Leave your comment"
-
          on:submit={async ({ detail: { comment, embeds } }) => {
-
            state = "submit";
-
            try {
-
              await editComment_(comment, Array.from(embeds.values()));
-
            } finally {
-
              state = "read";
-
            }
-
          }}
-
          on:close={async () => {
-
            body = body;
-
            await tick();
-
            state = "read";
-
          }} />
-
      {:else}
-
        <div style:overflow="hidden" style:width="100%">
-
          <Markdown breaks {rawPath} content={body} />
-
        </div>
-
      {/if}
+
      <div style:overflow="hidden" style:width="100%">
+
        <Markdown breaks {rawPath} content={body} />
+
      </div>
    </div>
  {/if}
-
  {#if (id && reactOnComment) || (id && reactions && reactions.length > 0)}
+
  {#if id && reactions && reactions.length > 0}
    <div class="actions">
-
      {#if id && reactOnComment}
-
        {@const reactOnComment_ = reactOnComment}
-
        <div class="global-hide-on-mobile-down">
-
          <ReactionSelector
-
            {reactions}
-
            on:select={async ({ detail: { authors, emoji } }) => {
-
              try {
-
                await reactOnComment_(authors, emoji);
-
              } finally {
-
                closeFocused();
-
              }
-
            }} />
-
        </div>
-
      {/if}
-
      {#if id && reactions && reactions.length > 0}
-
        <Reactions handleReaction={reactOnComment} {reactions} />
-
      {/if}
+
      <Reactions {reactions} />
    </div>
  {/if}
</div>
deleted src/components/CommentToggleInput.svelte
@@ -1,61 +0,0 @@
-
<script lang="ts">
-
  import type { Embed } from "@http-client";
-

-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
-

-
  export let body: string | undefined = undefined;
-
  export let placeholder: string | undefined = undefined;
-
  export let submitCaption: string | undefined = undefined;
-
  export let rawPath: string;
-
  export let enableAttachments: boolean = false;
-
  export let inline: boolean = false;
-
  export let focus: boolean = false;
-
  export let submit: (comment: string, embeds: Embed[]) => Promise<void>;
-

-
  let state: "collapsed" | "expanded" | "submit" = "collapsed";
-
</script>
-

-
<style>
-
  .inactive {
-
    box-shadow: 0 0 0 1px var(--color-border-hint);
-
    border-radius: var(--border-radius-small);
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-background-dip);
-
    font-size: var(--font-size-small);
-
    color: var(--color-fill-gray);
-
    cursor: text;
-
  }
-
  .inactive:hover {
-
    box-shadow: 0 0 0 1px var(--color-border-default);
-
  }
-
</style>
-

-
{#if state !== "collapsed"}
-
  <ExtendedTextarea
-
    {rawPath}
-
    {inline}
-
    {placeholder}
-
    {submitCaption}
-
    submitInProgress={state === "submit"}
-
    {focus}
-
    {body}
-
    {enableAttachments}
-
    on:close={() => (state = "collapsed")}
-
    on:submit={async ({ detail: { comment, embeds } }) => {
-
      try {
-
        state = "submit";
-
        await submit(comment, Array.from(embeds.values()));
-
      } finally {
-
        state = "collapsed";
-
      }
-
    }} />
-
{:else}
-
  <!-- svelte-ignore a11y-click-events-have-key-events -->
-
  <div
-
    class="inactive"
-
    role="button"
-
    tabindex="0"
-
    on:click={() => (state = "expanded")}>
-
    {placeholder}
-
  </div>
-
{/if}
deleted src/components/ConnectInstructions.svelte
@@ -1,73 +0,0 @@
-
<script>
-
  import { experimental } from "@app/lib/appearance";
-
  import { api, httpdStore } from "@app/lib/httpd";
-
  import { routeToPath, activeUnloadedRouteStore } from "@app/lib/router";
-

-
  import Command from "@app/components/Command.svelte";
-
  import ExternalLink from "./ExternalLink.svelte";
-

-
  $: path = routeToPath($activeUnloadedRouteStore);
-
  $: pathParam = path === "/" ? "" : `--path "${path}"`;
-
</script>
-

-
<style>
-
  .divider {
-
    height: 1px;
-
    width: 100%;
-
    background-color: var(--color-fill-separator);
-
    margin: 1rem 0;
-
  }
-

-
  .heading {
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-bold);
-
    margin-bottom: 0.75rem;
-
  }
-

-
  .label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
-
</style>
-

-
<div>
-
  {#if $experimental}
-
    {#if $httpdStore.state === "running"}
-
      <div class="label">
-
        Authenticate with your local node to make changes.
-
      </div>
-
      <Command
-
        fullWidth
-
        command={`rad web ${window.origin} --connect ${api.hostname}:${api.port} ${pathParam}`} />
-
    {:else}
-
      <div class="heading">Connect & Authenticate</div>
-
      <div class="label">
-
        Connect to your local node to browse repositories on your local machine,
-
        create issues, and participate in discussions.
-
      </div>
-
      <Command fullWidth command={`rad web ${window.origin} ${pathParam}`} />
-

-
      <div class="divider" />
-
      <div class="heading">New to Radicle?</div>
-
      <div class="label">
-
        Visit <ExternalLink href="https://radicle.xyz/#try" /> to download Radicle
-
        and get started.
-
      </div>
-
    {/if}
-
  {:else}
-
    <div class="heading">Browse your local repositories</div>
-
    <div class="label">
-
      To browse repositories on your local node, run the following command.
-
    </div>
-
    <Command fullWidth command="radicle-httpd" />
-

-
    <div class="divider" />
-
    <div class="heading">New to Radicle?</div>
-
    <div class="label">
-
      Visit <ExternalLink href="https://radicle.xyz/#try" /> to download Radicle
-
      and get started.
-
    </div>
-
  {/if}
-
</div>
deleted src/components/ExtendedTextarea.svelte
@@ -1,254 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Embed } from "@http-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import * as modal from "@app/lib/modal";
-
  import * as utils from "@app/lib/utils";
-
  import { embed } from "@app/lib/file";
-

-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-

-
  import Button from "./Button.svelte";
-
  import IconSmall from "./IconSmall.svelte";
-
  import Loading from "./Loading.svelte";
-
  import Markdown from "./Markdown.svelte";
-
  import Radio from "./Radio.svelte";
-
  import Textarea from "./Textarea.svelte";
-

-
  export let enableAttachments: boolean = false;
-
  export let placeholder: string = "Leave your comment";
-
  export let submitCaption: string = "Comment";
-
  export let focus: boolean = false;
-
  export let inline: boolean = false;
-
  export let rawPath: string;
-
  export let body: string = "";
-
  export let embeds: Map<string, Embed> = new Map();
-
  export let submitInProgress: boolean = false;
-
  export let disallowEmptyBody: boolean = false;
-
  export let isValid: () => boolean = () => {
-
    return true;
-
  };
-

-
  let preview: boolean = false;
-
  let selectionStart = 0;
-
  let selectionEnd = 0;
-
  let inputFiles: FileList | undefined = undefined;
-

-
  const inputId = `input-label-${crypto.randomUUID()}`;
-

-
  const dispatch = createEventDispatcher<{
-
    submit: { comment: string; embeds: Map<string, Embed> };
-
    close: null;
-
    click: null;
-
  }>();
-

-
  function submit() {
-
    dispatch("submit", { comment: body, embeds });
-
    preview = false;
-
  }
-

-
  const MAX_BLOB_SIZE = 4_194_304;
-

-
  function handleFileDrop(event: { detail: DragEvent }) {
-
    if (!enableAttachments) {
-
      return;
-
    }
-

-
    event.detail.preventDefault();
-
    if (event.detail.dataTransfer) {
-
      attachEmbeds(event.detail.dataTransfer.files);
-
    }
-
  }
-

-
  function handleFilePaste(event: ClipboardEvent) {
-
    // Always allow pasting text content.
-
    if (event.clipboardData && event.clipboardData.files.length === 0) {
-
      return;
-
    }
-

-
    if (!enableAttachments) {
-
      return;
-
    }
-

-
    event.preventDefault();
-
    if (event.clipboardData) {
-
      attachEmbeds(event.clipboardData.files);
-
    }
-
  }
-

-
  function handleFileSelect(event: Event) {
-
    if (!enableAttachments) {
-
      return;
-
    }
-

-
    event.preventDefault();
-
    if (inputFiles) {
-
      attachEmbeds(inputFiles);
-
    }
-
  }
-

-
  function attachEmbeds(files: FileList) {
-
    const embedPromise = Array.from(files).map(embed);
-
    void Promise.all(embedPromise).then(newEmbeds =>
-
      newEmbeds.forEach(({ oid, name, content }) => {
-
        if (content.length > MAX_BLOB_SIZE) {
-
          modal.show({
-
            component: ErrorModal,
-
            props: {
-
              title: "File too large",
-
              subtitle: [
-
                "The file you tried to upload is too large.",
-
                "The maximum file size is 4MB.",
-
              ],
-
              error: { message: `File ${embed.name} is too large` },
-
            },
-
          });
-
          return;
-
        }
-
        embeds.set(oid, { name, content });
-
        const embedText = `![${name}](${oid})\n`;
-
        body = body
-
          .slice(0, selectionStart)
-
          .concat(embedText, body.slice(selectionEnd));
-
        selectionStart += embedText.length;
-
        selectionEnd = selectionStart;
-
      }),
-
    );
-
  }
-
</script>
-

-
<style>
-
  .comment-section {
-
    border: 1px solid var(--color-border-hint);
-
    padding: 1rem;
-
    border-radius: var(--border-radius-small);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    gap: 1rem;
-
    width: 100%;
-
  }
-
  .inline {
-
    border: 0;
-
    padding: 0;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    width: 100%;
-
    gap: 1rem;
-
  }
-
  .buttons {
-
    display: flex;
-
    margin-left: auto;
-
    gap: 1rem;
-
  }
-
  .caption {
-
    font-size: var(--font-size-small);
-
    color: var(--color-fill-gray);
-
  }
-
  .preview {
-
    font-size: var(--font-size-small);
-
    min-height: 6.375rem;
-
    padding: 0.75rem;
-
    margin-left: 1px;
-
    margin-top: 1px;
-
  }
-
  label {
-
    color: var(--color-foreground-contrast);
-
  }
-
  label:hover {
-
    color: var(--color-foreground-primary);
-
  }
-
</style>
-

-
<div
-
  class="comment-section"
-
  aria-label="extended-textarea"
-
  title=""
-
  class:inline>
-
  <Radio>
-
    <Button
-
      styleBorderRadius="0"
-
      variant={!preview ? "selected" : "not-selected"}
-
      on:click={() => {
-
        preview = false;
-
      }}>
-
      <IconSmall name="edit" />
-
      Edit
-
    </Button>
-
    <div class="global-spacer" />
-
    <Button
-
      styleBorderRadius="0"
-
      disabled={disallowEmptyBody && body.length === 0}
-
      variant={preview ? "selected" : "not-selected"}
-
      on:click={() => {
-
        preview = true;
-
      }}>
-
      <IconSmall name="eye-open" />
-
      Preview
-
    </Button>
-
  </Radio>
-
  {#if preview}
-
    <div class="preview">
-
      <Markdown breaks {rawPath} {embeds} content={body} />
-
    </div>
-
  {:else}
-
    <input
-
      multiple
-
      bind:files={inputFiles}
-
      style:display="none"
-
      type="file"
-
      id={inputId}
-
      on:change={handleFileSelect} />
-
    <Textarea
-
      on:drop={handleFileDrop}
-
      on:paste={handleFilePaste}
-
      bind:selectionEnd
-
      bind:selectionStart
-
      {focus}
-
      on:submit={submit}
-
      bind:value={body}
-
      {placeholder} />
-
  {/if}
-
  <div class="actions">
-
    {#if !preview}
-
      <div class="caption">
-
        {#if enableAttachments}
-
          Add files by dragging & dropping, <label
-
            for={inputId}
-
            style:cursor="pointer">
-
            selecting
-
          </label>
-
          or pasting them.
-
        {/if}
-
        Markdown supported. Press {utils.modifierKey()}↵ to submit.
-
      </div>
-
    {/if}
-
    <div class="buttons">
-
      <Button
-
        disabled={submitInProgress}
-
        variant="outline"
-
        on:click={() => {
-
          preview = false;
-
          dispatch("close");
-
        }}>
-
        Cancel
-
      </Button>
-
      <Button
-
        variant="secondary"
-
        disabled={!isValid() ||
-
          submitInProgress ||
-
          (disallowEmptyBody && body.length === 0)}
-
        on:click={submit}>
-
        {#if submitInProgress}
-
          <Loading small noDelay />
-
        {:else}
-
          {submitCaption}
-
        {/if}
-
      </Button>
-
    </div>
-
  </div>
-
</div>
modified src/components/ProjectCard.svelte
@@ -17,8 +17,6 @@

  export let projectInfo: ProjectInfo;

-
  export let isDelegate: boolean;
-

  $: project = projectInfo.project;
  $: baseUrl = projectInfo.baseUrl;
  $: isPrivate = project.visibility?.type === "private";
@@ -157,14 +155,6 @@
              <IconSmall name="lock" />
            </div>
          {/if}
-
          {#if isDelegate}
-
            <div
-
              title="Delegate"
-
              class="badge"
-
              style="background-color: var(--color-fill-delegate); color: var(--color-foreground-primary)">
-
              <IconSmall name="badge" />
-
            </div>
-
          {/if}
          <Badge
            variant="neutral"
            size="tiny"
deleted src/components/ReactionSelector.svelte
@@ -1,72 +0,0 @@
-
<script lang="ts">
-
  import type { Comment } from "@http-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import config from "virtual:config";
-

-
  import IconButton from "./IconButton.svelte";
-
  import IconSmall from "./IconSmall.svelte";
-
  import Popover from "./Popover.svelte";
-

-
  export let reactions: Comment["reactions"] | undefined = undefined;
-

-
  const dispatch = createEventDispatcher<{
-
    select: Comment["reactions"][0];
-
  }>();
-
</script>
-

-
<style>
-
  .selector {
-
    display: flex;
-
    align-items: center;
-
    border-radius: var(--border-radius-tiny);
-
    padding: 0.4rem;
-
    gap: 0.2rem;
-
  }
-
  .selector button {
-
    border: 0;
-
    background-color: transparent;
-
  }
-
  .selector button.active {
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-fill-ghost);
-
  }
-
  .selector button:hover {
-
    cursor: pointer;
-
    border-radius: var(--border-radius-small);
-
    background-color: var(--color-fill-ghost);
-
  }
-
</style>
-

-
<div>
-
  <Popover
-
    popoverPositionBottom="2.5rem"
-
    popoverPositionLeft="0"
-
    popoverPadding="0">
-
    <IconButton
-
      slot="toggle"
-
      let:toggle
-
      on:click={toggle}
-
      title="toggle-reaction-popover">
-
      <IconSmall name="face" />
-
    </IconButton>
-
    <div class="selector" slot="popover">
-
      {#each config.reactions as reaction}
-
        {@const lookedUpReaction = reactions?.find(
-
          ({ emoji }) => emoji === reaction,
-
        )}
-
        <button
-
          class:active={Boolean(lookedUpReaction)}
-
          on:click={() => {
-
            dispatch(
-
              "select",
-
              lookedUpReaction || { emoji: reaction, authors: [] },
-
            );
-
          }}>
-
          {reaction}
-
        </button>
-
      {/each}
-
    </div>
-
  </Popover>
-
</div>
modified src/components/Reactions.svelte
@@ -1,15 +1,7 @@
<script lang="ts">
  import type { Comment } from "@http-client";

-
  import IconButton from "./IconButton.svelte";
-

  export let reactions: Comment["reactions"];
-
  export let handleReaction:
-
    | ((
-
        authors: Comment["reactions"][0]["authors"],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined;

  function authorsToTooltip(authors: Comment["reactions"][0]["authors"]) {
    return authors.map(a => a.alias ?? a.id).join("\n");
@@ -32,24 +24,10 @@
<div class="reactions">
  {#each reactions as { emoji, authors }}
    <div title={authorsToTooltip(authors)}>
-
      {#if handleReaction}
-
        <IconButton
-
          on:click={async () => {
-
            if (handleReaction) {
-
              await handleReaction(authors, emoji);
-
            }
-
          }}>
-
          <div class="reaction txt-tiny">
-
            <span>{emoji}</span>
-
            <span>{authors.length}</span>
-
          </div>
-
        </IconButton>
-
      {:else}
-
        <div class="reaction txt-tiny" style="padding: 2px 4px;">
-
          <span>{emoji}</span>
-
          <span>{authors.length}</span>
-
        </div>
-
      {/if}
+
      <div class="reaction txt-tiny" style="padding: 2px 4px;">
+
        <span>{emoji}</span>
+
        <span>{authors.length}</span>
+
      </div>
    </div>
  {/each}
</div>
deleted src/components/Textarea.svelte
@@ -1,138 +0,0 @@
-
<script lang="ts">
-
  import { afterUpdate, beforeUpdate, createEventDispatcher } from "svelte";
-
  import { isMac } from "@app/lib/utils";
-

-
  export let value: string | number | undefined = undefined;
-
  export let placeholder: string | undefined = undefined;
-
  export let focus: boolean = false;
-
  // If `false` we automatically grow the textarea height.
-
  // If `true` we show a resize handle on the lower right-hand side of the
-
  // textarea to allow resizing the textarea manually.
-
  export let resizable: boolean = false;
-

-
  // Defaulting selectionStart and selectionEnd to 0, since no full support yet.
-
  export let selectionStart: number = 0;
-
  export let selectionEnd: number = 0;
-

-
  let textareaElement: HTMLTextAreaElement | undefined = undefined;
-

-
  // We either auto-grow the textarea, or allow the user to resize it. These
-
  // options are mutually exclusive because a user resized textarea would
-
  // automatically shrink upon text input otherwise.
-
  $: if (textareaElement && !resizable) {
-
    // React to changes to the textarea content.
-
    value;
-

-
    // Reset height to 0px on every value change so that the textarea
-
    // immediately shrinks when all text is deleted.
-
    textareaElement.style.height = `0px`;
-
    textareaElement.style.height = `${textareaElement.scrollHeight}px`;
-
  }
-

-
  $: if (textareaElement && focus) {
-
    textareaElement.focus();
-
    focus = false;
-
  }
-

-
  beforeUpdate(() => {
-
    if (textareaElement) {
-
      ({ selectionStart, selectionEnd } = textareaElement);
-
    }
-
  });
-

-
  afterUpdate(() => {
-
    if (textareaElement && focus) {
-
      textareaElement.setSelectionRange(selectionStart, selectionEnd);
-
      textareaElement.focus();
-
    }
-
  });
-

-
  const dispatch = createEventDispatcher<{
-
    submit: null;
-
    drop: DragEvent;
-
  }>();
-

-
  let dragging: boolean = false;
-

-
  function handleKeydown(event: KeyboardEvent) {
-
    const auxiliarKey = isMac() ? event.metaKey : event.ctrlKey;
-
    if (auxiliarKey && event.key === "Enter") {
-
      dispatch("submit");
-
    }
-
    if (event.key === "Escape") {
-
      textareaElement?.blur();
-
    }
-
  }
-

-
  function handleDropAndForward(event: DragEvent) {
-
    dragging = false;
-
    dispatch("drop", event);
-
  }
-
</script>
-

-
<style>
-
  textarea {
-
    background-color: var(--color-background-dip);
-
    border: 1px solid var(--color-border-hint);
-
    color: var(--color-foreground-default);
-
    border-radius: var(--border-radius-small);
-
    font-family: inherit;
-
    height: 5rem;
-
    padding: 0.75rem;
-
    width: 100%;
-
    min-height: 6.375rem;
-
    resize: none;
-
    overflow: hidden;
-
    line-height: 1.625rem;
-
  }
-

-
  .resizable {
-
    resize: vertical;
-
    overflow: scroll;
-
  }
-

-
  textarea::-webkit-scrollbar-corner {
-
    background-color: transparent;
-
  }
-

-
  textarea::-webkit-resizer {
-
    background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAMAAAAolt3jAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAD9QTFRFAAAAZWZmZmZmZmVmZWVmwsLBwsLCZ2ZmwsPCZmdlZWZnwcLBZmZkYGJjw8LDwsPBZmZnZWZkZ2ZkwMDBWFtcNbXb2AAAABV0Uk5TAP///////////////////////1H/YDRrSAAAAFBJREFUeJxVjUESgCAMA2mqAoqK6P/f6kzjIXIos5NumpI8g5LbpJnNQvDl52mWUYTquqnXwstshpHaTi+o+hHXccoKmHVW9yvIxv218ntivmOYAWpLfqaRAAAAAElFTkSuQmCC);
-
    background-size: 7px;
-
    background-repeat: no-repeat;
-
    background-position: bottom 1px right 1px;
-
  }
-

-
  textarea::placeholder {
-
    color: var(--color-foreground-dim);
-
  }
-
  textarea:focus {
-
    border: 1px solid var(--color-border-default);
-
  }
-
  textarea:hover {
-
    border: 1px solid var(--color-border-default);
-
  }
-
  textarea:focus {
-
    border: 1px solid var(--color-fill-secondary);
-
  }
-
  .drag {
-
    border: 1px dashed var(--color-fill-secondary) !important;
-
  }
-
</style>
-

-
<textarea
-
  bind:this={textareaElement}
-
  bind:value
-
  aria-label="textarea-comment"
-
  class="txt-small"
-
  class:resizable
-
  class:drag={dragging}
-
  {placeholder}
-
  on:change
-
  on:click
-
  on:input
-
  on:drop={handleDropAndForward}
-
  on:paste
-
  on:dragenter={() => (dragging = true)}
-
  on:dragleave={() => (dragging = false)}
-
  on:keydown|stopPropagation={handleKeydown}
-
  on:keypress />
modified src/components/Thread.svelte
@@ -1,14 +1,7 @@
-
<script lang="ts" strictEvents>
-
  import type { Embed } from "@http-client";
+
<script lang="ts">
  import type { Comment } from "@http-client";

-
  import * as utils from "@app/lib/utils";
-
  import partial from "lodash/partial";
-
  import { parseEmbedIntoMap } from "@app/lib/file";
-
  import { tick } from "svelte";
-

  import CommentComponent from "@app/components/Comment.svelte";
-
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import IconSmall from "./IconSmall.svelte";

  export let thread: {
@@ -16,30 +9,6 @@
    replies: Comment[];
  };
  export let rawPath: string;
-
  export let enableAttachments: boolean = false;
-
  export let canEditComment: (author: string) => true | undefined;
-
  export let editComment:
-
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let createReply:
-
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let reactOnComment:
-
    | ((
-
        commentId: string,
-
        authors: Comment["reactions"][0]["authors"],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined;
-

-
  async function toggleReply() {
-
    // This tick allows the DOM to update before scrolling.
-
    await tick();
-
    utils.scrollIntoView(`reply-${root.id}`, {
-
      behavior: "smooth",
-
      block: "center",
-
    });
-
  }

  $: root = thread.root;
  $: replies = thread.replies;
@@ -64,9 +33,6 @@
  .replies {
    margin-left: 1.25rem;
  }
-
  .reply {
-
    padding: 1rem;
-
  }
  @media (max-width: 719.98px) {
    .comments {
      border-radius: 0;
@@ -77,20 +43,14 @@
<div class="comments">
  <div class="top-level-comment" class:has-replies={replies.length > 0}>
    <CommentComponent
-
      {enableAttachments}
      {rawPath}
      id={root.id}
      lastEdit={root.edits.length > 1 ? root.edits.pop() : undefined}
      authorId={root.author.id}
      authorAlias={root.author.alias}
      reactions={root.reactions}
-
      embeds={parseEmbedIntoMap(root.embeds)}
      timestamp={root.timestamp}
-
      body={root.body}
-
      editComment={editComment &&
-
        canEditComment(root.author.id) &&
-
        partial(editComment, root.id)}
-
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
+
      body={root.body}>
      <IconSmall name="chat" slot="icon" />
    </CommentComponent>
  </div>
@@ -98,7 +58,6 @@
    <div class="replies">
      {#each replies as reply}
        <CommentComponent
-
          {enableAttachments}
          {rawPath}
          lastEdit={reply.edits.length > 1 ? reply.edits.pop() : undefined}
          id={reply.id}
@@ -108,27 +67,9 @@
          isReply
          isLastReply={replies[replies.length - 1] === reply}
          reactions={reply.reactions}
-
          embeds={parseEmbedIntoMap(reply.embeds)}
          timestamp={reply.timestamp}
-
          body={reply.body}
-
          editComment={editComment &&
-
            canEditComment(reply.author.id) &&
-
            partial(editComment, reply.id)}
-
          reactOnComment={reactOnComment &&
-
            partial(reactOnComment, reply.id)} />
+
          body={reply.body} />
      {/each}
    </div>
  {/if}
-
  {#if createReply}
-
    <div id={`reply-${root.id}`} class="reply global-hide-on-mobile-down">
-
      <CommentToggleInput
-
        {rawPath}
-
        focus
-
        inline
-
        placeholder="Reply to comment"
-
        on:click={toggleReply}
-
        {enableAttachments}
-
        submit={partial(createReply, root.id)} />
-
    </div>
-
  {/if}
</div>
modified src/lib/appearance.ts
@@ -49,25 +49,3 @@ export function storeCodeFont(newCodeFont: CodeFont): void {
  codeFont.set(newCodeFont);
  window.localStorage.setItem("codefont", newCodeFont);
}
-

-
export const experimental = writable<true | undefined>(
-
  loadExperimentalSetting(),
-
);
-

-
function loadExperimentalSetting(): true | undefined {
-
  const storedExperimental = window.localStorage.getItem("experimental");
-

-
  if (storedExperimental === null) {
-
    return undefined;
-
  } else {
-
    return storedExperimental === "true" ? true : undefined;
-
  }
-
}
-

-
export function storeExperimental(newSetting: true | undefined): void {
-
  experimental.set(newSetting);
-
  window.localStorage.setItem(
-
    "experimental",
-
    newSetting === true ? "true" : "undefined",
-
  );
-
}
modified src/lib/file.ts
@@ -1,44 +1,3 @@
-
import type { Embed } from "@http-client";
-

-
async function parseGitOid(bytes: Uint8Array): Promise<string> {
-
  // Create the header
-
  const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
-

-
  // Concatenate the header and the original file content
-
  const combined = new Uint8Array(header.length + bytes.length);
-
  combined.set(header);
-
  combined.set(bytes, header.length);
-

-
  // Compute the SHA-1 hash
-
  const hashBuffer = await crypto.subtle.digest("SHA-1", combined);
-
  const hashArray = Array.from(new Uint8Array(hashBuffer));
-

-
  // Convert the hash to a hexadecimal string
-
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
-
}
-

-
function base64String(file: File): Promise<string> {
-
  return new Promise((resolve, reject) => {
-
    const reader = new FileReader();
-
    reader.onload = (event: ProgressEvent<FileReader>) => {
-
      if (event.target?.result && typeof event.target.result === "string") {
-
        resolve(event.target.result);
-
      } else {
-
        reject(new Error("Failed to generate base64 string"));
-
      }
-
    };
-

-
    reader.readAsDataURL(file);
-
  });
-
}
-

-
export function parseEmbedIntoMap(embeds: Embed[]) {
-
  return embeds.reduce((acc, embed) => {
-
    acc.set(embed.content.substring(4), embed);
-
    return acc;
-
  }, new Map());
-
}
-

const mimes: Record<string, string> = {
  "3gp": "video/3gpp",
  "7z": "application/x-7z-compressed",
@@ -102,11 +61,4 @@ const mimes: Record<string, string> = {
  zip: "application/zip",
};

-
async function embed(file: File) {
-
  const bytes = new Uint8Array(await file.arrayBuffer());
-
  const oid = await parseGitOid(bytes);
-
  const content = await base64String(file);
-
  return { oid, name: file.name, content };
-
}
-

-
export { embed, mimes };
+
export { mimes };
deleted src/lib/httpd.ts
@@ -1,246 +0,0 @@
-
import type { DefaultSeedingPolicy, Node } from "@http-client";
-

-
import { get, writable } from "svelte/store";
-
import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";
-

-
import { HttpdClient } from "@http-client";
-
import config from "virtual:config";
-
import { deduplicateStore } from "@app/lib/deduplicateStore";
-
import { experimental } from "./appearance";
-

-
export interface Session {
-
  id: string;
-
  publicKey: string;
-
  alias: string;
-
}
-

-
export interface HttpdNodeState {
-
  id: Node["id"];
-
  state: Node["state"];
-
  seedingPolicy: DefaultSeedingPolicy | undefined;
-
}
-

-
export type HttpdState =
-
  | { state: "stopped" }
-
  | {
-
      state: "running";
-
      node: HttpdNodeState;
-
    }
-
  | {
-
      state: "authenticated";
-
      session: Session;
-
      node: HttpdNodeState;
-
    };
-

-
const HTTPD_STATE_STORAGE_KEY = "httpdState";
-
const HTTPD_CUSTOM_PORT_KEY = "httpdCustomPort";
-

-
const store = writable<HttpdState>({ state: "stopped" });
-
export const httpdStore = deduplicateStore(store);
-

-
export const api = new HttpdClient({
-
  hostname: "127.0.0.1",
-
  port: config.nodes.defaultLocalHttpdPort,
-
  scheme: "http",
-
});
-

-
let pollHttpdStateHandle: number | undefined = undefined;
-

-
export function changeHttpdPort(port: number) {
-
  window.localStorage.setItem(HTTPD_CUSTOM_PORT_KEY, String(port));
-
  void checkState();
-
}
-

-
function update(state: HttpdState) {
-
  window.localStorage.setItem(HTTPD_STATE_STORAGE_KEY, JSON.stringify(state));
-
  store.set(state);
-
}
-

-
const stateMutex = withTimeout(new Mutex(), 5_000);
-

-
export async function authenticate(params: {
-
  id: string;
-
  signature: string;
-
  publicKey: string;
-
}): Promise<boolean> {
-
  if (!get(experimental)) {
-
    return false;
-
  }
-
  stateMutex.cancel();
-
  return stateMutex.runExclusive(async () => {
-
    try {
-
      await api.session.update(params.id, {
-
        sig: params.signature,
-
        pk: params.publicKey,
-
      });
-
      const { id, state, config } = await api.getNode();
-
      const sess = await api.session.getById(params.id);
-
      update({
-
        state: "authenticated",
-
        session: {
-
          id: params.id,
-
          publicKey: params.publicKey,
-
          alias: sess.alias,
-
        },
-
        node: {
-
          id,
-
          state,
-
          seedingPolicy: config?.seedingPolicy,
-
        },
-
      });
-
      return true;
-
    } catch (error) {
-
      console.error(error);
-
      update({ state: "stopped" });
-
      return false;
-
    }
-
  });
-
}
-

-
export async function disconnect() {
-
  stateMutex.cancel();
-
  await stateMutex
-
    .runExclusive(async () => {
-
      const httpd = get(store);
-
      if (httpd.state !== "authenticated") {
-
        return;
-
      }
-

-
      try {
-
        await api.session.delete(httpd.session.id);
-
        const { id, state, config } = await api.getNode();
-
        update({
-
          state: "running",
-
          node: {
-
            id,
-
            state,
-
            seedingPolicy: config?.seedingPolicy,
-
          },
-
        });
-
      } catch (error) {
-
        console.error(error);
-
        update({ state: "stopped" });
-
      }
-
    })
-
    .catch(error => {
-
      if (error !== E_CANCELED) {
-
        throw error;
-
      }
-
    });
-
}
-

-
async function checkState() {
-
  let httpdState: HttpdState | null = null;
-
  const rawHttpdState = window.localStorage.getItem(HTTPD_STATE_STORAGE_KEY);
-
  const customHttpdPort = window.localStorage.getItem(HTTPD_CUSTOM_PORT_KEY);
-
  if (customHttpdPort) {
-
    api.changePort(Number(customHttpdPort));
-
  }
-
  if (rawHttpdState) {
-
    try {
-
      httpdState = JSON.parse(rawHttpdState);
-
    } catch (error) {
-
      console.error(error);
-
      return;
-
    }
-
  }
-

-
  await stateMutex
-
    .runExclusive(async () => {
-
      try {
-
        const { id, state, config } = await api.getNode();
-
        const node = {
-
          id,
-
          state,
-
          seedingPolicy: config?.seedingPolicy,
-
        };
-

-
        if (httpdState && httpdState.state === "authenticated") {
-
          const sess = await api.session.getById(httpdState.session.id);
-
          const unixTimeInSeconds = Math.floor(Date.now() / 1000);
-
          if (
-
            sess.status === "unauthorized" ||
-
            sess.expiresAt < unixTimeInSeconds
-
          ) {
-
            update({
-
              state: "running",
-
              node,
-
            });
-
          } else {
-
            update({ ...httpdState, node });
-
          }
-
        } else {
-
          update({
-
            state: "running",
-
            node,
-
          });
-
        }
-
      } catch (error) {
-
        if (error instanceof TypeError && error.message !== "Failed to fetch") {
-
          console.error(error);
-
        }
-
        update({ state: "stopped" });
-
      }
-
    })
-
    .catch(error => {
-
      if (error !== E_CANCELED && error !== E_TIMEOUT) {
-
        throw error;
-
      }
-
    });
-
}
-

-
let windowFocus: boolean = false;
-
window.addEventListener("focus", () => (windowFocus = true));
-
window.addEventListener("blur", () => (windowFocus = false));
-

-
function pollSession() {
-
  if (pollHttpdStateHandle) {
-
    return;
-
  }
-

-
  pollHttpdStateHandle = window.setInterval(() => {
-
    // We only want to poll for the active browser instance & tab.
-
    // The other instances and tabs should only react to the storage event.
-
    if (!document.hidden && windowFocus) {
-
      void checkState();
-
    }
-
  }, 10_000);
-
}
-

-
export async function initialize() {
-
  // Sync session state changes with other open tabs and windows.
-
  addEventListener("storage", event => {
-
    if (
-
      event.key === HTTPD_STATE_STORAGE_KEY &&
-
      event.oldValue !== event.newValue
-
    ) {
-
      if (!event.newValue) {
-
        throw new Error("event.newValue was not set");
-
      }
-
      const httpdState: HttpdState = JSON.parse(event.newValue);
-
      store.set(httpdState);
-
    }
-

-
    if (
-
      event.key === HTTPD_CUSTOM_PORT_KEY &&
-
      event.oldValue !== event.newValue
-
    ) {
-
      api.changePort(Number(event.newValue));
-
    }
-
  });
-

-
  await checkState();
-

-
  // Properly clean up setInterval and restart session polling when Vite
-
  // performs hot module reload on file changes.
-
  if (import.meta.hot) {
-
    import.meta.hot.accept();
-
    import.meta.hot.dispose(() => {
-
      clearInterval(pollHttpdStateHandle);
-
      pollHttpdStateHandle = undefined;
-
      pollSession();
-
    });
-
  }
-

-
  pollSession();
-
}
deleted src/lib/projects.ts
@@ -1,36 +0,0 @@
-
import type { BaseUrl, Project } from "@http-client";
-

-
import { HttpdClient } from "@http-client";
-
import { isFulfilled } from "@app/lib/utils";
-

-
export interface ProjectBaseUrl {
-
  project: Project;
-
  baseUrl: BaseUrl;
-
}
-

-
export async function getProjectsFromNodes(
-
  params: { id: string; baseUrl: BaseUrl }[],
-
): Promise<ProjectBaseUrl[]> {
-
  const projectPromises = params.map(async param => {
-
    const api = new HttpdClient(param.baseUrl);
-
    const project = await api.project.getById(param.id);
-
    return {
-
      project,
-
      baseUrl: param.baseUrl,
-
    };
-
  });
-

-
  const results = await Promise.allSettled(projectPromises);
-
  return results.filter(isFulfilled).map(r => r.value);
-
}
-

-
export async function queryProject(
-
  baseUrl: BaseUrl,
-
  projectId: string,
-
): Promise<"found" | "notFound"> {
-
  const httpd = new HttpdClient(baseUrl);
-
  return await httpd.project
-
    .getById(projectId)
-
    .then<"found">(() => "found")
-
    .catch(() => "notFound");
-
}
deleted src/lib/roles.ts
@@ -1,41 +0,0 @@
-
import { parseNodeId } from "@app/lib/utils";
-
import { get } from "svelte/store";
-
import { experimental } from "./appearance";
-

-
export function isDelegate(
-
  publicKey: string | undefined,
-
  delegates: string[],
-
): true | undefined {
-
  if (!publicKey) {
-
    return undefined;
-
  }
-
  return (
-
    delegates.some(delegate => parseNodeId(delegate)?.pubkey === publicKey) ||
-
    undefined
-
  );
-
}
-

-
function matchAuthor(
-
  publicKey: string | undefined,
-
  nid: string,
-
): true | undefined {
-
  // Normalize the passed in NID
-
  const parsedNid = parseNodeId(nid);
-
  return (
-
    (publicKey && parsedNid && parsedNid.pubkey === publicKey) || undefined
-
  );
-
}
-

-
// All restricted actions are a combination of either:
-
// - the user is a delegate
-
// - the user is an author of the comment, issue, patch, etc.
-
//
-
// If the experimental setting isn't turned on, we return undefined early.
-
export function isDelegateOrAuthor(
-
  publicKey: string | undefined,
-
  delegates: string[],
-
  author: string,
-
) {
-
  if (get(experimental) === undefined) return undefined;
-
  return isDelegate(publicKey, delegates) || matchAuthor(publicKey, author);
-
}
modified src/lib/router.ts
@@ -121,7 +121,6 @@ function setTitle(loadedRoute: LoadedRoute) {
    loadedRoute.resource === "project.commit" ||
    loadedRoute.resource === "project.issue" ||
    loadedRoute.resource === "project.issues" ||
-
    loadedRoute.resource === "project.newIssue" ||
    loadedRoute.resource === "project.patches" ||
    loadedRoute.resource === "project.patch"
  ) {
@@ -132,9 +131,6 @@ function setTitle(loadedRoute: LoadedRoute) {
        ? "Local Node"
        : loadedRoute.params.baseUrl.hostname,
    );
-
  } else if (loadedRoute.resource === "session") {
-
    title.push("Authenticating");
-
    title.push("Radicle");
  } else {
    utils.unreachable(loadedRoute);
  }
@@ -205,22 +201,6 @@ function urlToRoute(url: URL): Route | null {
      }
      return null;
    }
-
    case "session": {
-
      const id = segments.shift();
-
      if (id) {
-
        return {
-
          resource: "session",
-
          params: {
-
            id,
-
            signature: url.searchParams.get("sig") ?? "",
-
            publicKey: url.searchParams.get("pk") ?? "",
-
            apiAddr: url.searchParams.get("addr") ?? "127.0.0.1:8080",
-
            path: url.searchParams.get("path") || undefined,
-
          },
-
        };
-
      }
-
      return { resource: "home" };
-
    }
    case "": {
      return { resource: "home" };
    }
@@ -233,8 +213,6 @@ function urlToRoute(url: URL): Route | null {
export function routeToPath(route: Route): string {
  if (route.resource === "home") {
    return "/";
-
  } else if (route.resource === "session") {
-
    return `/session?id=${route.params.id}&sig=${route.params.signature}&pk=${route.params.publicKey}`;
  } else if (route.resource === "nodes") {
    return nodePath(route.params.baseUrl);
  } else if (
@@ -242,7 +220,6 @@ export function routeToPath(route: Route): string {
    route.resource === "project.history" ||
    route.resource === "project.commit" ||
    route.resource === "project.issues" ||
-
    route.resource === "project.newIssue" ||
    route.resource === "project.issue" ||
    route.resource === "project.patches" ||
    route.resource === "project.patch"
modified src/lib/router/definitions.ts
@@ -9,7 +9,6 @@ import type {
} from "@app/views/projects/router";
import type { NodesRoute, NodesLoadedRoute } from "@app/views/nodes/router";

-
import { loadHomeRoute } from "@app/views/home/router";
import { loadProjectRoute } from "@app/views/projects/router";
import { loadNodeRoute } from "@app/views/nodes/router";

@@ -22,17 +21,6 @@ export interface NotFoundRoute {
  params: { title: string };
}

-
export interface SessionRoute {
-
  resource: "session";
-
  params: {
-
    id: string;
-
    signature: string;
-
    publicKey: string;
-
    apiAddr: string;
-
    path?: string;
-
  };
-
}
-

export type ErrorParam = Error | ResponseParseError | ResponseError | undefined;

export interface ErrorRoute {
@@ -50,8 +38,7 @@ export type Route =
  | ErrorRoute
  | NotFoundRoute
  | ProjectRoute
-
  | NodesRoute
-
  | SessionRoute;
+
  | NodesRoute;

export type LoadedRoute =
  | BootingRoute
@@ -59,8 +46,7 @@ export type LoadedRoute =
  | ErrorRoute
  | NotFoundRoute
  | ProjectLoadedRoute
-
  | NodesLoadedRoute
-
  | SessionRoute;
+
  | NodesLoadedRoute;

export async function loadRoute(
  route: Route,
@@ -69,13 +55,12 @@ export async function loadRoute(
  if (route.resource === "nodes") {
    return await loadNodeRoute(route.params);
  } else if (route.resource === "home") {
-
    return await loadHomeRoute();
+
    return { resource: "home", params: {} };
  } else if (
    route.resource === "project.source" ||
    route.resource === "project.history" ||
    route.resource === "project.commit" ||
    route.resource === "project.issues" ||
-
    route.resource === "project.newIssue" ||
    route.resource === "project.issue" ||
    route.resource === "project.patches" ||
    route.resource === "project.patch"
deleted src/modals/AuthenticatedModal.svelte
@@ -1,10 +0,0 @@
-
<script lang="ts">
-
  import Modal from "@app/components/Modal.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
</script>
-

-
<Modal title="Successfully authenticated" showCloseButton>
-
  <Icon name="review" size="48" slot="icon" />
-

-
  <div slot="subtitle">You're now connected to your local Radicle node.</div>
-
</Modal>
deleted src/modals/AuthenticationErrorModal.svelte
@@ -1,36 +0,0 @@
-
<script lang="ts">
-
  import { baseUrlToString } from "@app/lib/utils";
-
  import * as httpd from "@app/lib/httpd";
-

-
  import Modal from "@app/components/Modal.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-

-
  // @ts-expect-error https://github.com/microsoft/TypeScript/issues/41532
-
  const isBrave = navigator.brave !== undefined;
-
  const url = baseUrlToString(httpd.api.baseUrl);
-
</script>
-

-
<Modal title="Authentication failed" showCloseButton>
-
  <Icon name="alert" size="48" slot="icon" />
-

-
  <div slot="subtitle">
-
    Make sure your browser is able to connect to <a href={url}>{url}</a>
-
    &#x200B.
-

-
    <br />
-

-
    {#if isBrave}
-
      It seems like you're using Brave browser, to make authentication work, <br />
-
      disable trackers and ad blockers in settings/shields.
-
    {:else}
-
      Firewalls and ad blockers can interfere with authentication, <br />
-
      try disabling them and try again.
-
    {/if}
-

-
    <br />
-
    <br />
-
    If the above doesn't help, check for errors in the browser console and
-
    <br />
-
    in the terminal where you ran the auth command.
-
  </div>
-
</Modal>
modified src/views/home/Index.svelte
@@ -1,76 +1,31 @@
<script lang="ts">
  import type { ComponentProps } from "svelte";
  import type { ProjectInfo } from "@app/components/ProjectCard";
-
  import type { BaseUrl, ProjectListQuery } from "@http-client";

-
  import storedWritable from "@efstajas/svelte-stored-writable";
  import { derived } from "svelte/store";
-
  import { literal, union } from "zod";

-
  import { api, httpdStore } from "@app/lib/httpd";
  import { baseUrlToString } from "@app/lib/utils";
  import { deduplicateStore } from "@app/lib/deduplicateStore";
-
  import { experimental } from "@app/lib/appearance";
  import { fetchProjectInfos } from "@app/components/ProjectCard";
  import { handleError } from "@app/views/home/error";
-
  import { isDelegate } from "@app/lib/roles";
  import { preferredSeeds } from "@app/lib/seeds";

  import AppLayout from "@app/App/AppLayout.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";

-
  import Command from "@app/components/Command.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import FilterButton from "./components/FilterButton.svelte";
  import HomepageSection from "./components/HomepageSection.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import NewProjectButton from "./components/NewProjectButton.svelte";
-
  import Popover from "@app/components/Popover.svelte";
  import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";

-
  export let configPreferredSeeds: BaseUrl[];
-

  const selectedSeed = deduplicateStore(
    derived(preferredSeeds, $ => $?.selected),
  );
-
  const localProjectsFilterSchema = union([
-
    literal("all"),
-
    literal("delegating"),
-
  ]);
-
  const localProjectsFilter = storedWritable(
-
    "localProjectsFilter",
-
    localProjectsFilterSchema,
-
    "all",
-
  );

-
  let localProjects:
-
    | ProjectInfo[]
-
    | ComponentProps<ErrorMessage>["error"]
-
    | undefined;
  let preferredSeedProjects:
    | ProjectInfo[]
    | ComponentProps<ErrorMessage>["error"]
    | undefined;

-
  async function loadLocalProjects() {
-
    const query: ProjectListQuery = { show: "all" };
-
    await api
-
      .getStats()
-
      .then(({ repos: { total } }) => (query.perPage = total))
-
      .catch(e => {
-
        console.error(
-
          "Not able to query to total repo count for your local node.",
-
          e,
-
        );
-
      });
-

-
    localProjects = undefined;
-
    localProjects = await fetchProjectInfos(api.baseUrl, query).catch(
-
      error => error,
-
    );
-
  }
-

  async function loadPreferredSeedProjects() {
    preferredSeedProjects = undefined;

@@ -80,20 +35,7 @@
    }).catch(error => error);
  }

-
  $: nodeId = $httpdStore.state !== "stopped" ? $httpdStore.node.id : undefined;
-
  $: nodeId && void loadLocalProjects();
  $: $selectedSeed && void loadPreferredSeedProjects();
-
  $: filteredLocalProjects =
-
    $localProjectsFilter === "all" ||
-
    localProjects instanceof Error ||
-
    localProjects === undefined
-
      ? localProjects
-
      : localProjects.filter(p =>
-
          isDelegate(
-
            nodeId,
-
            p.project.delegates.map(d => d.id),
-
          ),
-
        );
</script>

<style>
@@ -143,65 +85,6 @@

<AppLayout>
  <div class="wrapper" style:padding-bottom="2.5rem">
-
    {#if nodeId}
-
      <div class="global-hide-on-mobile-down">
-
        <HomepageSection
-
          loading={$httpdStore.state !== "stopped" &&
-
            localProjects === undefined}
-
          empty={$httpdStore.state === "stopped" ||
-
            (filteredLocalProjects instanceof Array &&
-
              !filteredLocalProjects.length) ||
-
            localProjects instanceof Error}
-
          title="Local repositories">
-
          <svelte:fragment slot="subtitle">
-
            Repositories you're seeding with your local node
-
          </svelte:fragment>
-
          <svelte:fragment slot="actions">
-
            {#if $experimental}
-
              <FilterButton
-
                disabled={!nodeId}
-
                bind:value={$localProjectsFilter} />
-
              <NewProjectButton disabled={!nodeId} />
-
            {/if}
-
          </svelte:fragment>
-
          <svelte:fragment slot="empty">
-
            <div class="empty-state">
-
              {#if localProjects instanceof Error}
-
                <ErrorMessage
-
                  {...handleError(
-
                    localProjects,
-
                    baseUrlToString(api.baseUrl),
-
                  )} />
-
              {:else if !localProjects?.length}
-
                <div class="heading">No local repositories</div>
-
                <div class="label">
-
                  Seed or check out a repository to work with it on your local
-
                  node.
-
                </div>
-
              {:else}
-
                <div class="heading">Nothing to see here</div>
-
                <div class="label">
-
                  No local repositories matched your filter settings.
-
                </div>
-
              {/if}
-
            </div>
-
          </svelte:fragment>
-
          <div class="project-grid">
-
            {#if filteredLocalProjects && !(filteredLocalProjects instanceof Error)}
-
              {#each filteredLocalProjects as projectInfo}
-
                {@const delegates = projectInfo.project.delegates.map(
-
                  d => d.id,
-
                )}
-
                <ProjectCard
-
                  {projectInfo}
-
                  isDelegate={isDelegate(nodeId, delegates) ?? false} />
-
              {/each}
-
            {/if}
-
          </div>
-
        </HomepageSection>
-
      </div>
-
    {/if}
-

    <HomepageSection
      loading={preferredSeedProjects === undefined}
      empty={preferredSeedProjects instanceof Error ||
@@ -210,31 +93,11 @@
      <svelte:fragment slot="title">
        <div class="flex-icon-item" style:min-width="0">
          <span class="txt-large">Explore</span>
-
          <PreferredSeedDropdown
-
            initialPreferredSeeds={configPreferredSeeds}
-
            selectedSeed={$preferredSeeds.selected} />
+
          <PreferredSeedDropdown selectedSeed={$preferredSeeds.selected} />
        </div>
      </svelte:fragment>
      <svelte:fragment slot="subtitle">
        Pinned repositories on your selected seed node
-
        {#if !nodeId}
-
          <div class="global-hide-on-mobile-down">
-
            <Popover popoverPositionTop="2.5rem" popoverPositionLeft="0">
-
              <IconButton slot="toggle" let:toggle on:click={toggle}>
-
                <span style:color="var(--color-fill-gray)">
-
                  <IconSmall name="info" />
-
                </span>
-
              </IconButton>
-

-
              <div slot="popover" class="popover txt-small" style:width="15rem">
-
                <div style:padding-bottom="0.5rem">
-
                  To browse your local repositories, run:
-
                </div>
-
                <Command command="radicle-httpd" />
-
              </div>
-
            </Popover>
-
          </div>
-
        {/if}
      </svelte:fragment>
      <svelte:fragment slot="empty">
        <div class="empty-state">
@@ -242,7 +105,7 @@
            <ErrorMessage
              {...handleError(
                preferredSeedProjects,
-
                baseUrlToString(api.baseUrl),
+
                baseUrlToString($preferredSeeds.selected),
              )} />
          {:else}
            <div class="heading">No pinned repositories</div>
@@ -255,10 +118,7 @@
      <div class="project-grid">
        {#if preferredSeedProjects && !(preferredSeedProjects instanceof Error)}
          {#each preferredSeedProjects as projectInfo}
-
            {@const delegates = projectInfo.project.delegates.map(d => d.id)}
-
            <ProjectCard
-
              {projectInfo}
-
              isDelegate={isDelegate(nodeId, delegates) ?? false} />
+
            <ProjectCard {projectInfo} />
          {/each}
        {/if}
      </div>
deleted src/views/home/components/FilterButton.svelte
@@ -1,105 +0,0 @@
-
<script lang="ts">
-
  import type { ComponentProps } from "svelte";
-

-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import Button from "@app/components/Button.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-

-
  const stateOptions: {
-
    value: typeof value;
-
    title: string;
-
    description: string;
-
    iconName: ComponentProps<IconSmall>["name"];
-
  }[] = [
-
    {
-
      value: "all",
-
      title: "All seeding",
-
      description: "Show all repositories you’re seeding with your local node.",
-
      iconName: "seedling",
-
    },
-
    {
-
      value: "delegating",
-
      title: "Delegate only",
-
      description:
-
        "Show only repositories that you’re seeding and a delegate of.",
-
      iconName: "badge",
-
    },
-
  ];
-

-
  export let value: "all" | "delegating" = "all";
-
  export let disabled = false;
-

-
  let expanded = false;
-
</script>
-

-
<style>
-
  .popover-content {
-
    width: 20rem;
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
  }
-

-
  .label {
-
    display: flex;
-
    white-space: initial;
-
  }
-

-
  .label .text > * {
-
    display: flex;
-
    gap: 0.25rem;
-
    align-items: center;
-
  }
-

-
  .dim {
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<Popover
-
  bind:expanded
-
  popoverPositionTop="2.5rem"
-
  popoverPositionRight="0"
-
  popoverPadding="0.25rem"
-
  popoverBorderRadius="var(--border-radius-small)">
-
  <Button
-
    {disabled}
-
    variant="outline"
-
    let:toggle
-
    slot="toggle"
-
    on:click={toggle}>
-
    {#if value === "all"}
-
      <IconSmall name="seedling" />
-
      All seeding
-
    {:else}
-
      <IconSmall name="badge" />
-
      Only delegating
-
    {/if}
-
    <IconSmall name={expanded ? "chevron-up" : "chevron-down"} />
-
  </Button>
-

-
  <div class="popover-content" slot="popover">
-
    <DropdownList items={stateOptions}>
-
      <DropdownListItem
-
        on:click={() => {
-
          value = item.value;
-
          closeFocused();
-
        }}
-
        slot="item"
-
        let:item
-
        selected={item.value === value}>
-
        <div class="label">
-
          <div class="text txt-small">
-
            <span class="txt-bold">
-
              <IconSmall name={item.iconName} />{item.title}
-
            </span>
-
            <span class="dim">{item.description}</span>
-
          </div>
-
        </div>
-
      </DropdownListItem>
-
    </DropdownList>
-
  </div>
-
</Popover>
deleted src/views/home/components/NewProjectButton.svelte
@@ -1,38 +0,0 @@
-
<script lang="ts">
-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-

-
  export let disabled = false;
-
</script>
-

-
<style>
-
  .popover {
-
    min-width: 16rem;
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<Popover
-
  popoverPositionTop="2.5rem"
-
  popoverPositionRight="0"
-
  popoverBorderRadius="var(--border-radius-small)">
-
  <Button
-
    {disabled}
-
    variant="secondary"
-
    let:toggle
-
    slot="toggle"
-
    on:click={toggle}>
-
    <IconSmall name="plus" />
-
    New repository
-
  </Button>
-

-
  <div slot="popover" class="popover txt-small">
-
    Run the following command within an already-existing Git repository to
-
    create a new Radicle repository.
-
    <Command fullWidth command="rad init" />
-
  </div>
-
</Popover>
modified src/views/home/components/PreferredSeedDropdown.svelte
@@ -10,9 +10,7 @@
    selectPreferredSeed,
  } from "@app/lib/seeds";
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { httpdStore } from "@app/lib/httpd";

-
  import Command from "@app/components/Command.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import IconButton from "@app/components/IconButton.svelte";
@@ -20,7 +18,6 @@
  import Popover from "@app/components/Popover.svelte";
  import TextInput from "@app/components/TextInput.svelte";

-
  export let initialPreferredSeeds: BaseUrl[];
  export let selectedSeed: BaseUrl;

  const validateInput = async (seed: BaseUrl) => {
@@ -82,13 +79,6 @@
    background-color: var(--color-border-default);
  }

-
  .add-seed-node-instructions {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 0.5rem;
-
    padding: 0.5rem;
-
    color: var(--color-foreground-dim);
-
  }
  .icon-item {
    display: flex;
    gap: 0.5rem;
@@ -195,21 +185,6 @@
          </DropdownList>
        {/if}
      </div>
-
      {#if $httpdStore.state !== "stopped" && !initialPreferredSeeds.find(s => s.hostname === selectedSeed.hostname)}
-
        <div class="divider" />
-
        <div class="add-seed-node-instructions txt-small">
-
          <div class="txt-bold">Store in config</div>
-
          <div>
-
            Add <code style:word-break="break-all">
-
              {selectedSeed.hostname}
-
            </code>
-
            to your
-
            <code>preferredSeeds</code>
-
            in your Radicle config and restart httpd.
-
          </div>
-
          <Command fullWidth command="rad config edit" />
-
        </div>
-
      {/if}
    </div>
  </svelte:fragment>
</Popover>
modified src/views/home/router.ts
@@ -1,45 +1,8 @@
-
import type { BaseUrl } from "@http-client";
-
import type { ErrorRoute } from "@app/lib/router/definitions";
-

-
import * as seeds from "@app/lib/seeds";
-
import config from "virtual:config";
-
import { api, httpdStore } from "@app/lib/httpd";
-
import { get } from "svelte/store";
-

export interface HomeRoute {
  resource: "home";
}

export interface HomeLoadedRoute {
  resource: "home";
-
  params: { configPreferredSeeds: BaseUrl[] };
-
}
-

-
export async function loadHomeRoute(): Promise<HomeLoadedRoute | ErrorRoute> {
-
  if (get(httpdStore).state !== "stopped") {
-
    const profile = await api.profile.getProfile();
-
    const newValue = profile.config.preferredSeeds.map(seed => {
-
      const preferredSeedValue = seed?.split("@")[1];
-
      const preferredSeedOrigin = preferredSeedValue?.split(":")[0];
-

-
      return {
-
        hostname: preferredSeedOrigin,
-
        port: config.nodes.defaultHttpdPort,
-
        scheme: config.nodes.defaultHttpdScheme,
-
      };
-
    });
-
    if (get(seeds.configuredPreferredSeeds).length === 0) {
-
      seeds.addSeedsToConfiguredSeeds(newValue);
-
    }
-

-
    return {
-
      resource: "home",
-
      params: { configPreferredSeeds: newValue },
-
    };
-
  }
-

-
  return {
-
    resource: "home",
-
    params: { configPreferredSeeds: [] },
-
  };
+
  params: object;
}
modified src/views/nodes/View.svelte
@@ -4,7 +4,6 @@
  import { capitalize } from "lodash";

  import * as router from "@app/lib/router";
-
  import { api, httpdStore } from "@app/lib/httpd";
  import {
    baseUrlToString,
    formatUserAgent,
@@ -13,7 +12,6 @@
  } from "@app/lib/utils";
  import { fetchProjectInfos } from "@app/components/ProjectCard";
  import { handleError } from "@app/views/nodes/error";
-
  import { isDelegate } from "@app/lib/roles";

  import AppLayout from "@app/App/AppLayout.svelte";
  import IconButton from "@app/components/IconButton.svelte";
@@ -36,10 +34,6 @@
      ? "permissive"
      : "restrictive";
  $: hostname = isLocal(baseUrl.hostname) ? "Local Node" : baseUrl.hostname;
-
  $: session =
-
    $httpdStore.state === "authenticated" && isLocal(api.baseUrl.hostname)
-
      ? $httpdStore.session
-
      : undefined;
</script>

<style>
@@ -194,12 +188,7 @@
          {#if projectInfos.length > 0}
            <div class="project-grid">
              {#each projectInfos as projectInfo}
-
                <ProjectCard
-
                  {projectInfo}
-
                  isDelegate={isDelegate(
-
                    session?.publicKey,
-
                    projectInfo.project.delegates.map(d => d.id),
-
                  ) ?? false} />
+
                <ProjectCard {projectInfo} />
              {/each}
            </div>
          {:else}
@@ -211,7 +200,7 @@
            </div>
          {/if}
        {:catch error}
-
          {router.push(handleError(error, baseUrlToString(api.baseUrl)))}
+
          {router.push(handleError(error, baseUrlToString(baseUrl)))}
        {/await}
      </div>
    </div>
deleted src/views/projects/Cob/AssigneeInput.svelte
@@ -1,210 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { Reaction } from "@http-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import { formatNodeId, parseNodeId } from "@app/lib/utils";
-

-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  const dispatch = createEventDispatcher<{
-
    save: Reaction["authors"];
-
  }>();
-

-
  export let locallyAuthenticated: boolean = false;
-
  export let assignees: Reaction["authors"] = [];
-
  export let submitInProgress: boolean = false;
-

-
  let showInput: boolean = false;
-
  let updatedAssignees: Reaction["authors"] = assignees;
-
  let inputValue = "";
-
  let validationMessage: string | undefined = undefined;
-
  let assignee: string | undefined = undefined;
-

-
  const removeToggles: Record<string, boolean> = {};
-

-
  // Clear validationMessage if inputValue changes
-
  $: {
-
    inputValue;
-
    validationMessage = undefined;
-
  }
-

-
  function validateInput(input: string): boolean {
-
    if (input !== "") {
-
      const parsedNodeId = parseNodeId(inputValue);
-
      if (parsedNodeId) {
-
        assignee = `${parsedNodeId.prefix}${parsedNodeId.pubkey}`;
-
        if (updatedAssignees.find(({ id }) => id === assignee)) {
-
          validationMessage = "This assignee is already added";
-
          return false;
-
        } else {
-
          validationMessage = undefined;
-
          return true;
-
        }
-
      } else {
-
        validationMessage = "This assignee is not valid";
-
      }
-
    } else {
-
      validationMessage = "";
-
    }
-
    return false;
-
  }
-

-
  function addAssignee() {
-
    const valid = validateInput(inputValue);
-
    if (valid && assignee) {
-
      updatedAssignees = [...updatedAssignees, { id: assignee }];
-
      inputValue = "";
-
      dispatch("save", updatedAssignees);
-
      showInput = false;
-
    }
-
  }
-

-
  function removeAssignee(assignee: string) {
-
    updatedAssignees = updatedAssignees.filter(({ id }) => id !== assignee);
-
    dispatch("save", updatedAssignees);
-
    showInput = false;
-
  }
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .assignee {
-
    display: flex;
-
    align-items: center;
-
    width: 100%;
-
    gap: 0.25rem;
-
  }
-
  .validation-message {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
    color: var(--color-foreground-red);
-
    position: relative;
-
    margin-top: 0.5rem;
-
  }
-
  .input {
-
    width: 100%;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: flex-start;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .body {
-
      align-items: flex-start;
-
    }
-
    .no-assignees {
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .input {
-
      width: 18rem;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Assignees</div>
-
  <div class="body">
-
    {#if locallyAuthenticated}
-
      {#each updatedAssignees as assignee}
-
        <Badge
-
          variant="neutral"
-
          size="small"
-
          style="cursor: pointer; max-width: 14rem;"
-
          on:click={() =>
-
            (removeToggles[assignee.id] = !removeToggles[assignee.id])}>
-
          <div class="assignee">
-
            <Avatar inline nodeId={assignee.id} />
-
            <span class="txt-overflow">{formatNodeId(assignee.id)}</span>
-
            {#if removeToggles[assignee.id]}
-
              <IconButton title="remove assignee">
-
                <IconSmall
-
                  name="cross"
-
                  on:click={() => removeAssignee(assignee.id)} />
-
              </IconButton>
-
            {/if}
-
          </div>
-
        </Badge>
-
      {/each}
-
      {#if showInput}
-
        <div>
-
          <div class="input">
-
            <TextInput
-
              autofocus
-
              disabled={submitInProgress}
-
              bind:value={inputValue}
-
              placeholder="Add assignee"
-
              on:submit={addAssignee} />
-
            <IconButton
-
              title="discard assignee"
-
              on:click={() => {
-
                inputValue = "";
-
                validationMessage = undefined;
-
                showInput = false;
-
              }}>
-
              <IconSmall name="cross" />
-
            </IconButton>
-
            <IconButton title="save assignee" on:click={addAssignee}>
-
              <IconSmall name="checkmark" />
-
            </IconButton>
-
          </div>
-
          {#if validationMessage}
-
            <div class="validation-message">
-
              <IconSmall name="exclamation-circle" />{validationMessage}
-
            </div>
-
          {/if}
-
        </div>
-
      {:else}
-
        <div class="global-hide-on-mobile-down">
-
          <Badge
-
            variant="outline"
-
            size="small"
-
            title="add assignee"
-
            round
-
            on:click={() => (showInput = true)}>
-
            <IconSmall name="plus" />
-
          </Badge>
-
        </div>
-
      {/if}
-
    {:else}
-
      {#each updatedAssignees as assignee}
-
        <Badge variant="neutral" size="small">
-
          <div class="assignee">
-
            <Avatar inline nodeId={assignee.id} />
-
            <span>{formatNodeId(assignee.id)}</span>
-
          </div>
-
        </Badge>
-
      {:else}
-
        <div class="txt-missing no-assignees">No assignees</div>
-
      {/each}
-
    {/if}
-
  </div>
-
</div>
added src/views/projects/Cob/Assignees.svelte
@@ -0,0 +1,68 @@
+
<script lang="ts">
+
  import type { Reaction } from "@http-client";
+

+
  import { formatNodeId } from "@app/lib/utils";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+

+
  export let assignees: Reaction["authors"] = [];
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .assignee {
+
    display: flex;
+
    align-items: center;
+
    width: 100%;
+
    gap: 0.25rem;
+
  }
+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: flex-start;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .body {
+
      align-items: flex-start;
+
    }
+
    .no-assignees {
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
  }
+
</style>
+

+
<div class="wrapper">
+
  <div class="header">Assignees</div>
+
  <div class="body">
+
    {#each assignees as { id }}
+
      <Badge variant="neutral" size="small">
+
        <div class="assignee">
+
          <Avatar inline nodeId={id} />
+
          <span>{formatNodeId(id)}</span>
+
        </div>
+
      </Badge>
+
    {:else}
+
      <div class="txt-missing no-assignees">No assignees</div>
+
    {/each}
+
  </div>
+
</div>
deleted src/views/projects/Cob/CobStateButton.svelte
@@ -1,71 +0,0 @@
-
<script lang="ts" generics="CobState">
-
  import IconSmall from "@app/components/IconSmall.svelte";
-

-
  import isEqual from "lodash/isEqual";
-

-
  import { closeFocused } from "@app/components/Popover.svelte";
-

-
  import DropdownList from "@app/components/DropdownList.svelte";
-
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Button from "@app/components/Button.svelte";
-

-
  export let state: CobState;
-
  export let selectedItem: [string, CobState];
-
  export let items: [string, CobState][];
-
  export let save: (state: CobState) => Promise<void>;
-

-
  function switchCaption(item: [string, CobState]) {
-
    selectedItem = item;
-
    closeFocused();
-
  }
-
</script>
-

-
<style>
-
  .main {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: center;
-
    gap: 1px;
-
  }
-
</style>
-

-
<div class="main">
-
  <Button
-
    styleBorderRadius="var(--border-radius-tiny) 0 0 var(--border-radius-tiny)"
-
    variant="gray-white"
-
    on:click={() => void save(selectedItem[1])}>
-
    <IconSmall name="patch" />
-
    {selectedItem[0]}
-
  </Button>
-

-
  <Popover
-
    popoverPadding="0"
-
    popoverPositionTop="2.5rem"
-
    popoverPositionRight="0"
-
    popoverBorderRadius="var(--border-radius-small)">
-
    <Button
-
      slot="toggle"
-
      let:toggle
-
      on:click={toggle}
-
      styleBorderRadius="0 var(--border-radius-tiny) var(--border-radius-tiny) 0"
-
      stylePadding="0 0.25rem"
-
      variant="gray-white"
-
      ariaLabel="stateToggle">
-
      <Icon name="chevron-down" />
-
    </Button>
-
    <div slot="popover">
-
      <DropdownList items={items.filter(i => !isEqual(i, state))}>
-
        <svelte:fragment slot="item" let:item>
-
          <DropdownListItem
-
            selected={isEqual(item[1], selectedItem[1])}
-
            on:click={() => switchCaption(item)}>
-
            <IconSmall name="patch" />
-
            {item[0]}
-
          </DropdownListItem>
-
        </svelte:fragment>
-
      </DropdownList>
-
    </div>
-
  </Popover>
-
</div>
added src/views/projects/Cob/InlineLabels.svelte
@@ -0,0 +1,25 @@
+
<script lang="ts">
+
  import Badge from "@app/components/Badge.svelte";
+

+
  export let labels: string[];
+
</script>
+

+
<style>
+
  .label {
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
  }
+
</style>
+

+
{#each labels.slice(0, 2) as label}
+
  <Badge style="max-width:7rem" variant="neutral">
+
    <span class="label">{label}</span>
+
  </Badge>
+
{/each}
+
{#if labels.length > 2}
+
  <Badge title={labels.slice(2, undefined).join(" ")} variant="neutral">
+
    <span class="label">
+
      +{labels.length - 2} more
+
    </span>
+
  </Badge>
+
{/if}
deleted src/views/projects/Cob/LabelInput.svelte
@@ -1,181 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import { createEventDispatcher } from "svelte";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  const dispatch = createEventDispatcher<{ save: string[] }>();
-

-
  export let locallyAuthenticated: boolean = false;
-
  export let labels: string[] = [];
-
  export let submitInProgress: boolean = false;
-

-
  let showInput: boolean = false;
-
  let updatedLabels: string[] = labels;
-
  let inputValue = "";
-
  let validationMessage: string | undefined = undefined;
-
  let valid: boolean = false;
-
  let sanitizedValue: string | undefined = undefined;
-

-
  const removeToggles: Record<string, boolean> = {};
-

-
  $: {
-
    sanitizedValue = inputValue.trim();
-

-
    if (inputValue !== "") {
-
      if (sanitizedValue.length > 0) {
-
        if (updatedLabels.includes(sanitizedValue)) {
-
          valid = false;
-
          validationMessage = "This label is already added";
-
        } else {
-
          valid = true;
-
          validationMessage = undefined;
-
        }
-
      }
-
    } else {
-
      valid = false;
-
      validationMessage = "";
-
    }
-
  }
-

-
  function addLabel() {
-
    if (valid && sanitizedValue) {
-
      updatedLabels = [...updatedLabels, sanitizedValue];
-
      inputValue = "";
-
      dispatch("save", updatedLabels);
-
      showInput = false;
-
    }
-
  }
-

-
  function removeLabel(label: string) {
-
    updatedLabels = updatedLabels.filter(x => x !== label);
-
    dispatch("save", updatedLabels);
-
    showInput = false;
-
  }
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-small);
-
    margin-bottom: 0.75rem;
-
  }
-
  .body {
-
    display: flex;
-
    align-items: center;
-
    flex-wrap: wrap;
-
    flex-direction: row;
-
    gap: 0.5rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .validation-message {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.25rem;
-
    color: var(--color-foreground-red);
-
    position: relative;
-
    margin-top: 0.5rem;
-
  }
-
  .input {
-
    width: 100%;
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
-
  @media (max-width: 1349.98px) {
-
    .wrapper {
-
      display: flex;
-
      flex-direction: row;
-
      gap: 1rem;
-
      align-items: flex-start;
-
    }
-
    .header {
-
      margin-bottom: 0;
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .body {
-
      align-items: flex-start;
-
    }
-
    .no-labels {
-
      height: 2rem;
-
      display: flex;
-
      align-items: center;
-
    }
-
    .input {
-
      width: 18rem;
-
    }
-
  }
-
</style>
-

-
<div class="wrapper">
-
  <div class="header">Labels</div>
-
  <div class="body">
-
    {#if locallyAuthenticated}
-
      {#each updatedLabels as label}
-
        <Badge
-
          variant="neutral"
-
          size="small"
-
          style="cursor: pointer; max-width: 14rem;"
-
          on:click={() => (removeToggles[label] = !removeToggles[label])}>
-
          <div class="label txt-overflow">{label}</div>
-
          {#if removeToggles[label]}
-
            <IconButton title="remove label">
-
              <IconSmall name="cross" on:click={() => removeLabel(label)} />
-
            </IconButton>
-
          {/if}
-
        </Badge>
-
      {/each}
-
      {#if showInput}
-
        <div>
-
          <div class="input">
-
            <TextInput
-
              autofocus
-
              {valid}
-
              disabled={submitInProgress}
-
              placeholder="Add label"
-
              bind:value={inputValue}
-
              on:submit={addLabel} />
-
            <IconButton
-
              title="discard label"
-
              on:click={() => {
-
                inputValue = "";
-
                showInput = false;
-
              }}>
-
              <IconSmall name="cross" />
-
            </IconButton>
-
            <IconButton title="save label" on:click={addLabel}>
-
              <IconSmall name="checkmark" />
-
            </IconButton>
-
          </div>
-
          {#if !valid && validationMessage}
-
            <div class="validation-message">
-
              <IconSmall name="exclamation-circle" />{validationMessage}
-
            </div>
-
          {/if}
-
        </div>
-
      {:else}
-
        <div class="global-hide-on-mobile-down">
-
          <Badge
-
            variant="outline"
-
            size="small"
-
            title="add labels"
-
            round
-
            on:click={() => (showInput = true)}>
-
            <IconSmall name="plus"></IconSmall>
-
          </Badge>
-
        </div>
-
      {/if}
-
    {:else}
-
      {#each updatedLabels as label}
-
        <Badge variant="neutral" size="small">
-
          {label}
-
        </Badge>
-
      {:else}
-
        <div class="txt-missing no-labels">No labels</div>
-
      {/each}
-
    {/if}
-
  </div>
-
</div>
modified src/views/projects/Cob/Labels.svelte
@@ -1,25 +1,55 @@
<script lang="ts">
  import Badge from "@app/components/Badge.svelte";

-
  export let labels: string[];
+
  export let labels: string[] = [];
</script>

<style>
-
  .label {
-
    overflow: hidden;
-
    text-overflow: ellipsis;
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.75rem;
+
  }
+
  .body {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  @media (max-width: 1349.98px) {
+
    .wrapper {
+
      display: flex;
+
      flex-direction: row;
+
      gap: 1rem;
+
      align-items: flex-start;
+
    }
+
    .header {
+
      margin-bottom: 0;
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
+
    .body {
+
      align-items: flex-start;
+
    }
+
    .no-labels {
+
      height: 2rem;
+
      display: flex;
+
      align-items: center;
+
    }
  }
</style>

-
{#each labels.slice(0, 2) as label}
-
  <Badge style="max-width:7rem" variant="neutral">
-
    <span class="label">{label}</span>
-
  </Badge>
-
{/each}
-
{#if labels.length > 2}
-
  <Badge title={labels.slice(2, undefined).join(" ")} variant="neutral">
-
    <span class="label">
-
      +{labels.length - 2} more
-
    </span>
-
  </Badge>
-
{/if}
+
<div class="wrapper">
+
  <div class="header">Labels</div>
+
  <div class="body">
+
    {#each labels as label}
+
      <Badge variant="neutral" size="small">
+
        {label}
+
      </Badge>
+
    {:else}
+
      <div class="txt-missing no-labels">No labels</div>
+
    {/each}
+
  </div>
+
</div>
modified src/views/projects/Cob/Revision.svelte
@@ -3,7 +3,6 @@
    BaseUrl,
    Comment,
    DiffResponse,
-
    Embed,
    PatchState,
    Revision,
    Verdict,
@@ -12,9 +11,7 @@

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@http-client";
-
  import { closeFocused } from "@app/components/Popover.svelte";
  import { onMount } from "svelte";
-
  import { parseEmbedIntoMap } from "@app/lib/file";

  import CobCommitTeaser from "@app/views/projects/Cob/CobCommitTeaser.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
@@ -23,7 +20,6 @@
  import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import ExpandButton from "@app/components/ExpandButton.svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import IconButton from "@app/components/IconButton.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";
@@ -31,7 +27,6 @@
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Popover from "@app/components/Popover.svelte";
-
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
  import Thread from "@app/components/Thread.svelte";
  import Id from "@app/components/Id.svelte";
@@ -55,29 +50,6 @@
  export let previousRevId: string | undefined = undefined;
  export let previousRevOid: string | undefined = undefined;
  export let first: boolean;
-
  export let canEdit: (author: string) => true | undefined;
-
  export let editRevision:
-
    | ((description: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let editComment:
-
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;
-
  export let reactOnRevision:
-
    | ((
-
        authors: Comment["reactions"][0]["authors"],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined;
-
  export let reactOnComment:
-
    | ((
-
        commentId: string,
-
        authors: Comment["reactions"][0]["authors"],
-
        reaction: string,
-
      ) => Promise<void>)
-
    | undefined;
-
  export let createReply:
-
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
-
    | undefined;

  let expanded = initiallyExpanded;
  const api = new HttpdClient(baseUrl);
@@ -119,13 +91,10 @@
    }
  }

-
  type State = "read" | "submit" | "edit";
-

  let response: DiffResponse | undefined = undefined;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let error: any | undefined = undefined;
  let loading: boolean = false;
-
  let revisionState: State = "read";

  $: fromCommit =
    previousRevBase !== revisionBase
@@ -439,35 +408,9 @@
            <div
              class="global-hide-on-mobile-down"
              style="display: flex; gap: 0.5rem; margin-left: auto;">
-
              {#if canEdit(revisionAuthor.id) && editRevision && revisionState === "read"}
-
                <IconButton
-
                  title="edit revision"
-
                  on:click={() => (revisionState = "edit")}>
-
                  <IconSmall name="edit" />
-
                </IconButton>
-
              {/if}
            </div>
          </div>
-
          {#if editRevision && lastEdit && revisionState !== "read"}
-
            {@const editRevision_ = editRevision}
-
            <ExtendedTextarea
-
              enableAttachments
-
              embeds={parseEmbedIntoMap(lastEdit.embeds)}
-
              rawPath={rawPath(revisionId)}
-
              body={revisionDescription}
-
              submitCaption="Save"
-
              submitInProgress={revisionState === "submit"}
-
              placeholder="Leave a description"
-
              on:close={() => (revisionState = "read")}
-
              on:submit={async ({ detail: { comment, embeds } }) => {
-
                revisionState = "submit";
-
                try {
-
                  await editRevision_(comment, Array.from(embeds.values()));
-
                } finally {
-
                  revisionState = "read";
-
                }
-
              }} />
-
          {:else if revisionDescription && !first}
+
          {#if revisionDescription && !first}
            <div class="revision-description txt-small">
              <Markdown
                breaks
@@ -475,29 +418,11 @@
                content={revisionDescription} />
            </div>
          {/if}
-
          {#if reactOnRevision || revisionReactions.length > 0}
-
            <div class="actions">
-
              {#if reactOnRevision}
-
                {@const reactOnRevision_ = reactOnRevision}
-
                <div class="global-hide-on-mobile-down">
-
                  <ReactionSelector
-
                    reactions={revisionReactions}
-
                    on:select={async ({ detail: { emoji, authors } }) => {
-
                      try {
-
                        await reactOnRevision_(authors, emoji);
-
                      } finally {
-
                        closeFocused();
-
                      }
-
                    }} />
-
                </div>
-
              {/if}
-
              {#if revisionReactions && revisionReactions.length > 0}
-
                <Reactions
-
                  handleReaction={reactOnRevision}
-
                  reactions={revisionReactions} />
-
              {/if}
-
            </div>
-
          {/if}
+
          <div class="actions">
+
            {#if revisionReactions && revisionReactions.length > 0}
+
              <Reactions reactions={revisionReactions} />
+
            {/if}
+
          </div>
        </div>
        {#if loading}
          <div style:height="3.5rem">
@@ -534,14 +459,7 @@
      {#each timelines as element}
        {#if element.type === "thread"}
          <div class="connector" />
-
          <Thread
-
            enableAttachments
-
            thread={element.inner}
-
            rawPath={rawPath(revisionBase)}
-
            canEditComment={canEdit}
-
            {editComment}
-
            {createReply}
-
            {reactOnComment} />
+
          <Thread thread={element.inner} rawPath={rawPath(revisionBase)} />
        {:else if element.type === "merge"}
          <div class="connector" />
          <div class="action merge">
deleted src/views/projects/Header/SeedButton.svelte
@@ -1,153 +0,0 @@
-
<script lang="ts">
-
  import * as modal from "@app/lib/modal";
-
  import { experimental } from "@app/lib/appearance";
-
  import { httpdStore, api } from "@app/lib/httpd";
-

-
  import Button from "@app/components/Button.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import ExternalLink from "@app/components/ExternalLink.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
-

-
  export let projectId: string;
-
  export let seedCount: number;
-
  export let seeding: boolean;
-

-
  let editSeedingInProgress = false;
-

-
  async function editSeeding() {
-
    if ($httpdStore.state === "authenticated") {
-
      try {
-
        editSeedingInProgress = true;
-
        if (seeding) {
-
          await api.stopSeedingById(projectId, $httpdStore.session.id);
-
        } else {
-
          await api.seedById(projectId, $httpdStore.session.id);
-
        }
-
        seeding = !seeding;
-
      } catch (error) {
-
        if (error instanceof Error) {
-
          modal.show({
-
            component: ErrorModal,
-
            props: {
-
              title: seeding
-
                ? "Stop seeding repository failed"
-
                : "Seeding repository failed",
-
              subtitle: [
-
                `There was an error while trying to ${
-
                  seeding ? "stop seeding" : "seed"
-
                } this repository.`,
-
                "Check your radicle-httpd logs for details.",
-
              ],
-
              error: {
-
                message: error.message,
-
                stack: error.stack,
-
              },
-
            },
-
          });
-
        }
-
      } finally {
-
        editSeedingInProgress = false;
-
      }
-
    }
-
  }
-

-
  $: canEditSeeding =
-
    !editSeedingInProgress &&
-
    $httpdStore.state === "authenticated" &&
-
    $httpdStore.node.state === "running";
-
</script>
-

-
<style>
-
  .seed-label {
-
    display: block;
-
    font-size: var(--font-size-small);
-
    font-weight: var(--font-weight-regular);
-
    margin-bottom: 0.75rem;
-
  }
-
  .title-counter {
-
    display: flex;
-
    gap: 0.5rem;
-
  }
-
  .counter {
-
    font-weight: var(--font-weight-regular);
-
    border-radius: var(--border-radius-tiny);
-
    background-color: var(--color-fill-ghost-hover);
-
    border: 1px solid var(--color-border-secondary-counter);
-
    color: var(--color-foreground-contrast);
-
    padding: 0 0.25rem;
-
  }
-
  .seeding {
-
    background-color: var(--color-fill-counter-emphasized);
-
    color: var(--color-foreground-emphasized);
-
  }
-
  .not-seeding {
-
    background-color: var(--color-fill-secondary-counter);
-
    color: var(--color-foreground-match-background);
-
  }
-
  .disabled {
-
    background-color: var(--color-fill-float-hover);
-
    color: var(--color-foreground-disabled);
-
  }
-
</style>
-

-
<Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
  <Button
-
    slot="toggle"
-
    disabled={$experimental ? !canEditSeeding : false}
-
    let:toggle
-
    on:click={async () => {
-
      if ($experimental && !seeding && canEditSeeding) {
-
        await editSeeding();
-
        closeFocused();
-
      } else {
-
        toggle();
-
      }
-
    }}
-
    variant={seeding ? "secondary-toggle-on" : "secondary-toggle-off"}>
-
    <IconSmall name="seedling" />
-
    <span class="title-counter">
-
      {seeding ? "Seeding" : "Seed"}
-
      <span
-
        class="counter"
-
        class:seeding
-
        class:not-seeding={!seeding}
-
        class:disabled={$experimental ? !canEditSeeding : false}
-
        style:font-weight="var(--font-weight-regular)">
-
        {seedCount}
-
      </span>
-
    </span>
-
  </Button>
-

-
  <div
-
    slot="popover"
-
    let:toggle
-
    style:width={$experimental ? (seeding ? "19.5rem" : "30.5rem") : "auto"}>
-
    {#if $experimental && canEditSeeding && seeding}
-
      <div class="seed-label txt-bold">Stop seeding</div>
-
      <div class="seed-label">
-
        Are you sure you want to stop seeding this repository? If you don't seed
-
        a repository it won't appear in the local repositories section anymore
-
        and any changes you make to it won't propagate to the network.
-
      </div>
-
      <Button
-
        styleWidth="100%"
-
        disabled={editSeedingInProgress}
-
        on:click={async () => {
-
          await editSeeding();
-
          toggle();
-
        }}>
-
        <IconSmall name="seedling" />
-
        Stop seeding
-
      </Button>
-
    {:else}
-
      <span class="seed-label">
-
        Use the <ExternalLink href="https://radicle.xyz">
-
          Radicle CLI
-
        </ExternalLink> to {seeding ? "stop" : "start"} seeding this repository.
-
      </span>
-
      <Command command={`rad ${seeding ? "unseed" : "seed"} ${projectId}`} />
-
    {/if}
-
  </div>
-
</Popover>
deleted src/views/projects/Header/ShareButton.svelte
@@ -1,189 +0,0 @@
-
<script lang="ts">
-
  import type { ProjectRoute } from "@app/views/projects/router";
-

-
  import { onMount } from "svelte";
-

-
  import { activeUnloadedRouteStore, routeToPath } from "@app/lib/router";
-
  import { api } from "@app/lib/httpd";
-
  import config from "virtual:config";
-
  import { formatPublicExplorer } from "@app/lib/utils";
-
  import { queryProject } from "@app/lib/projects";
-

-
  import Clipboard from "@app/components/Clipboard.svelte";
-
  import Command from "@app/components/Command.svelte";
-
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import IconButton from "@app/components/IconButton.svelte";
-

-
  let usedFallbackSeed = false;
-

-
  let publicExplorer: string;
-
  let seedRoutes: ProjectRoute[] = [];
-

-
  onMount(async () => {
-
    const profile = await api.profile.getProfile().catch(() => undefined);
-
    const route = $activeUnloadedRouteStore as ProjectRoute;
-
    publicExplorer =
-
      profile?.config.publicExplorer || config.nodes.fallbackPublicExplorer;
-
    const preferredSeeds = profile?.config.preferredSeeds || [];
-

-
    seedRoutes = preferredSeeds.map(seed => {
-
      const [, address] = seed.split("@");
-
      return {
-
        ...route,
-
        node: {
-
          hostname: address.split(":")[0],
-
          port: config.nodes.defaultHttpdPort,
-
          scheme: config.nodes.defaultHttpdScheme,
-
        },
-
      };
-
    });
-

-
    if (preferredSeeds.length === 0) {
-
      usedFallbackSeed = true;
-
      seedRoutes.push({
-
        ...route,
-
        node: {
-
          hostname: config.nodes.defaultHttpdHostname,
-
          port: config.nodes.defaultHttpdPort,
-
          scheme: config.nodes.defaultHttpdScheme,
-
        },
-
      });
-
    }
-
  });
-
</script>
-

-
<style>
-
  .share {
-
    width: 22rem;
-
    font-size: var(--font-size-small);
-
  }
-
  .seed-list {
-
    padding: 0;
-
    margin: 1rem 0 0 0;
-
  }
-
  li.seed-item:not(:last-child) {
-
    margin-bottom: 0.5rem;
-
  }
-

-
  .seed-item {
-
    display: flex;
-
    flex-direction: row;
-
    justify-content: space-between;
-
    align-items: center;
-
    width: 100%;
-
    height: 2rem;
-
  }
-
  .seed {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 0.5rem;
-
    width: 100%;
-
    min-width: 0;
-
    margin-right: 0.5rem;
-
  }
-

-
  .help {
-
    display: flex;
-
    flex-direction: column;
-
    border-top: 1px solid var(--color-fill-separator);
-
    gap: 1rem;
-
    padding-top: 1rem;
-
    margin-top: 1rem;
-
  }
-
  .notFound {
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<div class="share">
-
  <div class="txt-bold" style:padding-bottom="0.5rem">
-
    You're on your local node
-
  </div>
-
  <div>
-
    Copy a link to this page on a public seed node, accessible by everyone.
-
  </div>
-
  <ul class="seed-list">
-
    {#each seedRoutes as seed}
-
      {#await queryProject(seed.node, seed.project)}
-
        <li class="seed-item">
-
          <span class="seed txt-bold">
-
            <IconSmall name="seedling" />
-
            <span class="txt-overflow">
-
              {seed.node.hostname}/{seed.project}
-
            </span>
-
          </span>
-
          <span style:height="1.5rem">
-
            <Loading center small noDelay condensed />
-
          </span>
-
        </li>
-
      {:then state}
-
        {@const path = routeToPath(seed)}
-
        <li
-
          class="seed-item"
-
          class:notFound={state === "notFound"}
-
          title={state === "notFound"
-
            ? "Not available on this public seed node"
-
            : ""}>
-
          <div class="seed txt-bold">
-
            <IconSmall name="seedling" />
-
            <span class="txt-overflow">
-
              {path.replace("/nodes/", "")}
-
            </span>
-
          </div>
-
          <div style="display: flex; align-items: center;">
-
            {#if state === "found"}
-
              <IconButton>
-
                <Clipboard
-
                  text={formatPublicExplorer(
-
                    publicExplorer,
-
                    seed.node.hostname,
-
                    seed.project,
-
                    path,
-
                  )} />
-
              </IconButton>
-
              <IconButton>
-
                <a
-
                  href={formatPublicExplorer(
-
                    publicExplorer,
-
                    seed.node.hostname,
-
                    seed.project,
-
                    path,
-
                  )}
-
                  target="_blank"
-
                  style=" width: 1.5rem;
-
                height: 1.5rem; display: flex; align-items: center; justify-content: center;">
-
                  <IconSmall name="arrow-box-up-right" />
-
                </a>
-
              </IconButton>
-
            {:else}
-
              <IconButton>
-
                <IconSmall name="clipboard" />
-
              </IconButton>
-
              <IconButton>
-
                <IconSmall name="arrow-box-up-right" />
-
              </IconButton>
-
            {/if}
-
          </div>
-
        </li>
-
      {/await}
-
    {/each}
-
    {#if usedFallbackSeed}
-
      <div class="help">
-
        <div>
-
          <div class="txt-bold" style:padding-bottom="0.5rem">
-
            Add more seed nodes
-
          </div>
-
          <div>
-
            Update preferred seeds in your Radicle config and restart httpd.
-
          </div>
-
        </div>
-
        <div style="display: flex; gap: 0.5rem; flex-direction: column;">
-
          <div>Run the following command to locate your config:</div>
-
          <Command command="rad self --config" fullWidth />
-
        </div>
-
      </div>
-
    {/if}
-
  </ul>
-
</div>
modified src/views/projects/History.svelte
@@ -32,7 +32,6 @@
  export let project: Project;
  export let revision: string | undefined;
  export let tree: Tree;
-
  export let seeding: boolean;

  const api = new HttpdClient(baseUrl);

@@ -91,7 +90,7 @@
</style>

<Layout {seedingPolicy} {baseUrl} {project} activeTab="source">
-
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+
  <ProjectNameHeader {project} {baseUrl} slot="header" />

  <div style:margin="1rem" slot="subheader">
    <Header
modified src/views/projects/Issue.svelte
@@ -1,51 +1,24 @@
<script lang="ts">
-
  import type { Reaction } from "@http-client/lib/project/comment";
-
  import type {
-
    BaseUrl,
-
    Comment,
-
    Embed,
-
    Issue,
-
    IssueState,
-
    Project,
-
    SeedingPolicy,
-
  } from "@http-client";
-
  import type { Session } from "@app/lib/httpd";
+
  import type { BaseUrl, Issue, Project, SeedingPolicy } from "@http-client";

  import capitalize from "lodash/capitalize";
-
  import isEqual from "lodash/isEqual";
  import uniqBy from "lodash/uniqBy";
-
  import partial from "lodash/partial";

-
  import * as modal from "@app/lib/modal";
-
  import * as role from "@app/lib/roles";
-
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
-
  import { experimental } from "@app/lib/appearance";
-
  import { HttpdClient } from "@http-client";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { httpdStore } from "@app/lib/httpd";
-
  import { parseEmbedIntoMap } from "@app/lib/file";

-
  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
+
  import Assignees from "@app/views/projects/Cob/Assignees.svelte";
  import Badge from "@app/components/Badge.svelte";
-
  import Button from "@app/components/Button.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
-
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Id from "@app/components/Id.svelte";
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import LabelInput from "./Cob/LabelInput.svelte";
+
  import Labels from "@app/views/projects/Cob/Labels.svelte";
  import Layout from "./Layout.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
-
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
  import Share from "@app/views/projects/Share.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";

  export let baseUrl: BaseUrl;
@@ -54,339 +27,10 @@
  export let project: Project;
  export let rawPath: (commit?: string) => string;

-
  const api = new HttpdClient(baseUrl);
-

-
  const items: [string, IssueState][] = [
-
    ["Reopen issue", { status: "open" }],
-
    ["Close issue as solved", { status: "closed", reason: "solved" }],
-
    ["Close issue as other", { status: "closed", reason: "other" }],
-
  ];
-

-
  async function createReply(
-
    sessionId: string,
-
    replyTo: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "comment", body, embeds, replyTo },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Comment reply creation failed",
-
            subtitle: [
-
              "There was an error while creating this reply.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function createComment(
-
    sessionId: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "comment", body, embeds, replyTo: issue.id },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      console.error(error);
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Comment creation failed",
-
            subtitle: [
-
              "There was an error while creating this comment.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function editComment(
-
    sessionId: string,
-
    id: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "comment.edit", id, body, embeds },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Issue comment editing failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function reactOnComment(
-
    session: Session,
-
    commentId: string,
-
    authors: Comment["reactions"][0]["authors"],
-
    reaction: string,
-
  ) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        {
-
          type: "comment.react",
-
          id: commentId,
-
          reaction,
-
          active: !authors.find(
-
            ({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
-
          ),
-
        },
-
        session.id,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Editing reactions failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function editIssue(
-
    sessionId: string,
-
    title: string,
-
    id: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "edit", title },
-
        sessionId,
-
      );
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "comment.edit", id, body, embeds },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Issue editing failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function saveLabels(labels: string[]) {
-
    try {
-
      if (session) {
-
        labelState = "submit";
-
        await api.project.updateIssue(
-
          project.id,
-
          issue.id,
-
          { type: "label", labels },
-
          session.id,
-
        );
-
      }
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Issue labels editing failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      labelState = "read";
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function saveAssignees(assignees: Reaction["authors"]) {
-
    try {
-
      if (session) {
-
        assigneeState = "submit";
-
        await api.project.updateIssue(
-
          project.id,
-
          issue.id,
-
          { type: "assign", assignees: assignees.map(({ id }) => id) },
-
          session.id,
-
        );
-
      }
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Issue assignees editing failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      assigneeState = "read";
-
      await refreshIssue();
-
    }
-
  }
-

-
  async function saveStatus(sessionId: string, state: IssueState) {
-
    try {
-
      await api.project.updateIssue(
-
        project.id,
-
        issue.id,
-
        { type: "lifecycle", state },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Issue status editing failed",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      void router.push({
-
        resource: "project.issue",
-
        project: project.id,
-
        node: baseUrl,
-
        issue: issue.id,
-
      });
-
    }
-
  }
-

-
  // Refreshes the given issue by fetching it from the server.
-
  // If the fetch fails, the given issue is returned.
-
  async function refreshIssue() {
-
    try {
-
      issue = await api.project.getIssueById(project.id, issue.id);
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Unable to fetch issue",
-
            subtitle: [
-
              "There was an error while refreshing this issue.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    }
-
  }
-

-
  let newTitle = issue.title;
-
  let newDescription = issue.discussion[0].body;
-

  $: uniqueEmbeds = uniqBy(
    issue.discussion.flatMap(comment => comment.embeds),
    "content",
  );
-
  $: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
  $: threads = issue.discussion
    .filter(
      comment =>
@@ -401,21 +45,10 @@
          .sort((a, b) => a.timestamp - b.timestamp),
      };
    }, []);
-
  $: session =
-
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
-
      ? $httpdStore.session
-
      : undefined;
  $: lastDescriptionEdit =
    issue.discussion[0].edits.length > 1
      ? issue.discussion[0].edits.at(-1)
      : undefined;
-
  $: delegates = project.delegates.map(d => d.id);
-

-
  type State = "read" | "edit" | "submit";
-

-
  let assigneeState: State = "read";
-
  let labelState: State = "read";
-
  let issueState: State = "read";
</script>

<style>
@@ -495,45 +128,16 @@
      <CobHeader>
        <svelte:fragment slot="title">
          <div style="display: flex; gap: 1rem; width: 100%;">
-
            {#if issueState !== "read"}
-
              <TextInput
-
                placeholder="Title"
-
                bind:value={newTitle}
-
                showKeyHint={false} />
-
            {:else if !issue.title}
-
              <span class="txt-missing">No title</span>
-
            {:else}
+
            {#if issue.title}
              <div class="title">
-
                <InlineTitle fontSize="large" content={newTitle} />
+
                <InlineTitle fontSize="large" content={issue.title} />
              </div>
+
            {:else}
+
              <span class="txt-missing">No title</span>
            {/if}
          </div>
          <div style="display: flex; gap: 0.5rem;">
-
            {#if $experimental && session && role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id) && issueState === "read"}
-
              <div class="global-hide-on-mobile-down">
-
                <Button
-
                  variant="outline"
-
                  title="edit issue"
-
                  on:click={() => (issueState = "edit")}>
-
                  <IconSmall name={"edit"} />
-
                  Edit
-
                </Button>
-
              </div>
-
            {/if}
-
            {#if issueState === "read"}
-
              <Share {baseUrl} />
-
              {#if $experimental && session && role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id)}
-
                <div class="global-hide-on-small-desktop-down">
-
                  <CobStateButton
-
                    items={items.filter(
-
                      ([, state]) => !isEqual(state, issue.state),
-
                    )}
-
                    {selectedItem}
-
                    state={issue.state}
-
                    save={partial(saveStatus, session.id)} />
-
                </div>
-
              {/if}
-
            {/if}
+
            <Share {baseUrl} />
          </div>
        </svelte:fragment>
        <svelte:fragment slot="state">
@@ -570,62 +174,13 @@
          <div
            style:margin-top="2rem"
            style="display: flex; flex-direction: column; gap: 0.5rem;">
-
            <AssigneeInput
-
              locallyAuthenticated={role.isDelegate(
-
                session?.publicKey,
-
                delegates,
-
              )}
-
              assignees={issue.assignees}
-
              submitInProgress={assigneeState === "submit"}
-
              on:save={({ detail: newAssignees }) => {
-
                void saveAssignees(newAssignees);
-
              }} />
-
            <LabelInput
-
              locallyAuthenticated={role.isDelegate(
-
                session?.publicKey,
-
                delegates,
-
              )}
-
              labels={issue.labels}
-
              submitInProgress={labelState === "submit"}
-
              on:save={({ detail: newLabels }) => void saveLabels(newLabels)} />
+
            <Assignees assignees={issue.assignees} />
+
            <Labels labels={issue.labels} />
            <Embeds embeds={uniqueEmbeds} />
          </div>
        </div>
        <svelte:fragment slot="description">
-
          {#if $experimental && issueState !== "read"}
-
            <ExtendedTextarea
-
              isValid={() => newTitle.length > 0}
-
              disallowEmptyBody
-
              rawPath={rawPath(project.head)}
-
              enableAttachments
-
              embeds={parseEmbedIntoMap(issue.discussion[0].embeds)}
-
              body={newDescription}
-
              submitCaption="Save"
-
              submitInProgress={issueState === "submit"}
-
              placeholder="Leave a description"
-
              on:close={() => {
-
                issueState = "read";
-
                newTitle = issue.title;
-
                newDescription = issue.discussion[0].body;
-
              }}
-
              on:submit={async ({ detail: { comment, embeds } }) => {
-
                if (session) {
-
                  try {
-
                    issueState = "submit";
-
                    await editIssue(
-
                      session.id,
-
                      newTitle,
-
                      issue.id,
-
                      comment,
-
                      Array.from(embeds.values()),
-
                    );
-
                    newDescription = comment;
-
                  } finally {
-
                    issueState = "read";
-
                  }
-
                }
-
              }} />
-
          {:else if issue.discussion[0].body}
+
          {#if issue.discussion[0].body}
            <Markdown
              breaks
              content={issue.discussion[0].body}
@@ -634,26 +189,8 @@
            <span class="txt-missing">No description</span>
          {/if}
          <div class="reactions">
-
            {#if $experimental && session}
-
              <div class="global-hide-on-mobile-down">
-
                <ReactionSelector
-
                  reactions={issue.discussion[0].reactions}
-
                  on:select={async ({ detail: { authors, emoji } }) => {
-
                    try {
-
                      if (session) {
-
                        await reactOnComment(session, issue.id, authors, emoji);
-
                      }
-
                    } finally {
-
                      closeFocused();
-
                    }
-
                  }} />
-
              </div>
-
            {/if}
            {#if issue.discussion[0].reactions.length > 0}
-
              <Reactions
-
                reactions={issue.discussion[0].reactions}
-
                handleReaction={session &&
-
                  partial(reactOnComment, session, issue.id)} />
+
              <Reactions reactions={issue.discussion[0].reactions} />
            {/if}
          </div>
        </svelte:fragment>
@@ -663,73 +200,18 @@
          <div class="connector" />
          <div class="threads">
            {#each threads as thread, i (thread.root.id)}
-
              <ThreadComponent
-
                enableAttachments
-
                {thread}
-
                rawPath={rawPath(project.head)}
-
                canEditComment={partial(
-
                  role.isDelegateOrAuthor,
-
                  session?.publicKey,
-
                  delegates,
-
                )}
-
                editComment={$experimental &&
-
                  session &&
-
                  partial(editComment, session.id)}
-
                createReply={$experimental &&
-
                  session &&
-
                  partial(createReply, session.id)}
-
                reactOnComment={$experimental &&
-
                  session &&
-
                  partial(reactOnComment, session)} />
+
              <ThreadComponent {thread} rawPath={rawPath(project.head)} />
              {#if i < threads.length - 1}
                <div class="connector" />
              {/if}
            {/each}
          </div>
        {/if}
-
        {#if $experimental && session}
-
          <div class="global-hide-on-mobile-down">
-
            <div class="connector" />
-
            <CommentToggleInput
-
              focus
-
              rawPath={rawPath(project.head)}
-
              placeholder="Leave your comment"
-
              enableAttachments
-
              submit={partial(createComment, session.id)} />
-
            <div
-
              style="display:flex; flex-direction: column; align-items: flex-start;">
-
              {#if role.isDelegateOrAuthor(session.publicKey, delegates, issue.author.id)}
-
                <div class="connector" />
-
                <CobStateButton
-
                  items={items.filter(
-
                    ([, state]) => !isEqual(state, issue.state),
-
                  )}
-
                  {selectedItem}
-
                  state={issue.state}
-
                  save={partial(saveStatus, session.id)} />
-
              {/if}
-
            </div>
-
          </div>
-
        {/if}
      </div>
    </div>
    <div class="metadata global-hide-on-medium-desktop-down">
-
      <AssigneeInput
-
        locallyAuthenticated={Boolean(
-
          role.isDelegate(session?.publicKey, delegates),
-
        )}
-
        assignees={issue.assignees}
-
        submitInProgress={assigneeState === "submit"}
-
        on:save={({ detail: newAssignees }) => {
-
          void saveAssignees(newAssignees);
-
        }} />
-
      <LabelInput
-
        locallyAuthenticated={Boolean(
-
          role.isDelegate(session?.publicKey, delegates),
-
        )}
-
        labels={issue.labels}
-
        submitInProgress={labelState === "submit"}
-
        on:save={({ detail: newLabels }) => void saveLabels(newLabels)} />
+
      <Assignees assignees={issue.assignees} />
+
      <Labels labels={issue.labels} />
      <Embeds embeds={uniqueEmbeds} />
    </div>
  </div>
modified src/views/projects/Issue/IssueTeaser.svelte
@@ -3,15 +3,14 @@

  import { absoluteTimestamp, formatTimestamp } from "@app/lib/utils";

+
  import CommentCounter from "../CommentCounter.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineLabels from "../Cob/InlineLabels.svelte";
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";

-
  import CommentCounter from "../CommentCounter.svelte";
-
  import Labels from "../Cob/Labels.svelte";
-
  import Id from "@app/components/Id.svelte";
-

  export let baseUrl: BaseUrl;
  export let issue: Issue;
  export let projectId: string;
@@ -101,7 +100,7 @@
        <span
          class="global-hide-on-small-desktop-down"
          style="display: inline-flex; gap: 0.5rem;">
-
          <Labels labels={issue.labels} />
+
          <InlineLabels labels={issue.labels} />
        </span>
      {/if}
      <div class="right">
@@ -115,7 +114,7 @@
        <div
          class="global-hide-on-medium-desktop-up"
          style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
-
          <Labels labels={issue.labels} />
+
          <InlineLabels labels={issue.labels} />
        </div>
      {/if}
      <div
deleted src/views/projects/Issue/New.svelte
@@ -1,164 +0,0 @@
-
<script lang="ts">
-
  import type {
-
    BaseUrl,
-
    Embed,
-
    Project,
-
    Reaction,
-
    SeedingPolicy,
-
  } from "@http-client";
-

-
  import * as modal from "@app/lib/modal";
-
  import * as router from "@app/lib/router";
-
  import * as utils from "@app/lib/utils";
-
  import { HttpdClient } from "@http-client";
-
  import { httpdStore } from "@app/lib/httpd";
-

-
  import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
-
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
-
  import Layout from "@app/views/projects/Layout.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let seedingPolicy: SeedingPolicy;
-
  export let project: Project;
-
  export let rawPath: (commit?: string) => string;
-

-
  let issueTitle = "";
-
  let assignees: Reaction["authors"] = [];
-
  let labels: string[] = [];
-

-
  const api = new HttpdClient(baseUrl);
-

-
  async function createIssue(
-
    sessionId: string,
-
    title: string,
-
    description: string,
-
    embeds: Map<string, Embed>,
-
  ) {
-
    try {
-
      const result = await api.project.createIssue(
-
        project.id,
-
        {
-
          title,
-
          description,
-
          assignees: assignees.map(a => a.id),
-
          embeds: [...embeds.values()],
-
          labels,
-
        },
-
        sessionId,
-
      );
-

-
      void router.push({
-
        resource: "project.issue",
-
        project: project.id,
-
        node: baseUrl,
-
        issue: result.id,
-
      });
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Coult not create issue",
-
            subtitle: [
-
              "There was an error while updating the issue.",
-
              "Make sure you're authenticated.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      } else {
-
        console.error(error);
-
      }
-
    }
-
  }
-

-
  $: session =
-
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
-
      ? $httpdStore.session
-
      : undefined;
-
</script>
-

-
<style>
-
  .form {
-
    display: flex;
-
    min-height: 100%;
-
  }
-
  .metadata {
-
    display: flex;
-
    flex-direction: column;
-
    font-size: var(--font-size-small);
-
    padding: 1rem;
-
    border-left: 1px solid var(--color-border-hint);
-
    gap: 1.5rem;
-
    width: 20rem;
-
  }
-
  .editor {
-
    flex: 2;
-
  }
-
</style>
-

-
<Layout {seedingPolicy} {baseUrl} {project} activeTab="issues">
-
  {#if session}
-
    {@const session_ = session}
-
    <div class="form">
-
      <div class="editor" style="padding: 1rem;">
-
        <CobHeader>
-
          <svelte:fragment slot="title">
-
            <TextInput
-
              placeholder="Title"
-
              autofocus
-
              bind:value={issueTitle}
-
              showKeyHint={false} />
-
          </svelte:fragment>
-
          <svelte:fragment slot="description">
-
            <ExtendedTextarea
-
              rawPath={rawPath(project.head)}
-
              disallowEmptyBody
-
              isValid={() => issueTitle.length > 0}
-
              enableAttachments
-
              submitCaption="Submit"
-
              placeholder="Write a description"
-
              on:submit={async ({ detail: { comment, embeds } }) => {
-
                await createIssue(session_.id, issueTitle, comment, embeds);
-
              }}
-
              on:close={() => {
-
                void router.push({
-
                  resource: "project.issues",
-
                  project: project.id,
-
                  node: baseUrl,
-
                });
-
              }} />
-
          </svelte:fragment>
-
        </CobHeader>
-
      </div>
-
      <div class="metadata">
-
        <AssigneeInput
-
          locallyAuthenticated={session &&
-
            project.delegates
-
              .map(d => d.id)
-
              .includes(`did:key:${session.publicKey}`)}
-
          on:save={({ detail: updatedAssignees }) =>
-
            (assignees = updatedAssignees)} />
-
        <LabelInput
-
          locallyAuthenticated={session &&
-
            project.delegates
-
              .map(d => d.id)
-
              .includes(`did:key:${session.publicKey}`)}
-
          on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
-
      </div>
-
    </div>
-
  {:else}
-
    <ErrorMessage
-
      title="Not able to create a new issue"
-
      description="Couldn't access issue creation. Make sure you're authenticated." />
-
  {/if}
-
</Layout>
modified src/views/projects/Issues.svelte
@@ -10,10 +10,8 @@
  import capitalize from "lodash/capitalize";
  import { HttpdClient } from "@http-client";
  import { ISSUES_PER_PAGE } from "./router";
-
  import { baseUrlToString, isLocal } from "@app/lib/utils";
+
  import { baseUrlToString } from "@app/lib/utils";
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { experimental } from "@app/lib/appearance";
-
  import { httpdStore } from "@app/lib/httpd";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -166,21 +164,6 @@

    <div style="margin-left: auto; display: flex; gap: 1rem;">
      <Share {baseUrl} />
-
      {#if $experimental && $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
-
        <div class="global-hide-on-mobile-down">
-
          <Link
-
            route={{
-
              resource: "project.newIssue",
-
              project: project.id,
-
              node: baseUrl,
-
            }}>
-
            <Button variant="secondary">
-
              <IconSmall name="plus" />
-
              New Issue
-
            </Button>
-
          </Link>
-
        </div>
-
      {/if}
    </div>
  </div>

modified src/views/projects/Patch.svelte
@@ -4,8 +4,6 @@
    Review,
    Merge,
    Project,
-
    LifecycleState,
-
    PatchState,
    Revision,
    Diff,
    SeedingPolicy,
@@ -42,54 +40,37 @@
</script>

<script lang="ts">
-
  import type { BaseUrl, Embed, Patch } from "@http-client";
+
  import type { BaseUrl, Patch } from "@http-client";
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
  import type { ComponentProps } from "svelte";
-
  import type { Session } from "@app/lib/httpd";

-
  import * as modal from "@app/lib/modal";
-
  import * as role from "@app/lib/roles";
-
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import capitalize from "lodash/capitalize";
-
  import isEqual from "lodash/isEqual";
-
  import partial from "lodash/partial";
  import uniqBy from "lodash/uniqBy";
-
  import { HttpdClient } from "@http-client";
-
  import { closeFocused } from "@app/components/Popover.svelte";
-
  import { experimental } from "@app/lib/appearance";
-
  import { httpdStore } from "@app/lib/httpd";
-
  import { parseEmbedIntoMap } from "@app/lib/file";

  import Badge from "@app/components/Badge.svelte";
  import Button from "@app/components/Button.svelte";
  import Changeset from "@app/views/projects/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
-
  import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
-
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import CompareButton from "@app/views/projects/Patch/CompareButton.svelte";
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
  import Embeds from "@app/views/projects/Cob/Embeds.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-
  import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
  import Id from "@app/components/Id.svelte";
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
-
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
+
  import Labels from "@app/views/projects/Cob/Labels.svelte";
  import Layout from "@app/views/projects/Layout.svelte";
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import Radio from "@app/components/Radio.svelte";
-
  import ReactionSelector from "@app/components/ReactionSelector.svelte";
  import Reactions from "@app/components/Reactions.svelte";
  import Reviews from "@app/views/projects/Cob/Reviews.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import RevisionSelector from "@app/views/projects/Patch/RevisionSelector.svelte";
  import Share from "@app/views/projects/Share.svelte";
-
  import TextInput from "@app/components/TextInput.svelte";

  export let baseUrl: BaseUrl;
  export let seedingPolicy: SeedingPolicy;
@@ -99,384 +80,6 @@
  export let project: Project;
  export let view: PatchView;

-
  $: api = new HttpdClient(baseUrl);
-

-
  const items: [string, LifecycleState][] = [
-
    ["Reopen patch", { status: "open" }],
-
    ["Archive patch", { status: "archived" }],
-
    ["Convert to draft", { status: "draft" }],
-
  ];
-

-
  async function editPatch(sessionId: string, title: string) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        { type: "edit", title, target: "delegates" },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch title editing failed",
-
            subtitle: [
-
              "There was an error while updating the title of this patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function editRevision(
-
    sessionId: string,
-
    revisionId: string,
-
    description: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        { type: "revision.edit", revision: revisionId, description, embeds },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Revision editing failed",
-
            subtitle: [
-
              "There was an error while updating the revision.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function createReply(
-
    sessionId: string,
-
    revisionId: string,
-
    replyTo: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        {
-
          type: "revision.comment",
-
          revision: revisionId,
-
          body,
-
          embeds,
-
          replyTo,
-
        },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch comment reply creation failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function reactOnComment(
-
    session: Session,
-
    revisionId: string,
-
    commentId: string,
-
    authors: Comment["reactions"][0]["authors"],
-
    reaction: string,
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        {
-
          type: "revision.comment.react",
-
          revision: revisionId,
-
          comment: commentId,
-
          reaction,
-
          active: !authors.find(
-
            ({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
-
          ),
-
        },
-
        session.id,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch comment reaction editing failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-
  async function createComment(
-
    sessionId: string,
-
    revisionId: string,
-
    comment: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        {
-
          type: "revision.comment",
-
          body: comment,
-
          embeds,
-
          revision: revisionId,
-
        },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch comment creation failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function editComment(
-
    sessionId: string,
-
    revisionId: string,
-
    id: string,
-
    body: string,
-
    embeds: Embed[],
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        {
-
          type: "revision.comment.edit",
-
          comment: id,
-
          body,
-
          revision: revisionId,
-
          embeds,
-
        },
-
        sessionId,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch comment editing failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function saveStatus(sessionId: string, state: PatchState) {
-
    try {
-
      if (state.status !== "merged") {
-
        await api.project.updatePatch(
-
          project.id,
-
          patch.id,
-
          { type: "lifecycle", state },
-
          sessionId,
-
        );
-
      }
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch status change failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      void router.push({
-
        resource: "project.patch",
-
        project: project.id,
-
        node: baseUrl,
-
        patch: patch.id,
-
      });
-
    }
-
  }
-

-
  async function reactOnRevision(
-
    session: Session,
-
    revisionId: string,
-
    authors: Revision["reactions"][0]["authors"],
-
    reaction: string,
-
  ) {
-
    try {
-
      await api.project.updatePatch(
-
        project.id,
-
        patch.id,
-
        {
-
          type: "revision.react",
-
          revision: revisionId,
-
          reaction,
-
          active: !authors.find(
-
            ({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
-
          ),
-
        },
-
        session.id,
-
      );
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Reacting on revision failed",
-
            subtitle: [
-
              "There was an error while trying to react to a revision.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      await refreshPatch();
-
    }
-
  }
-

-
  async function saveLabels(labels: string[]) {
-
    try {
-
      if (session) {
-
        labelState = "submit";
-
        await api.project.updatePatch(
-
          project.id,
-
          patch.id,
-
          { type: "label", labels },
-
          session.id,
-
        );
-
      }
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Patch label change failed",
-
            subtitle: [
-
              "There was an error while updating the patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    } finally {
-
      labelState = "read";
-
      await refreshPatch();
-
    }
-
  }
-

-
  // Refreshes the given patch by fetching it from the server.
-
  // If the fetch fails, the given patch is returned.
-
  async function refreshPatch() {
-
    try {
-
      patch = await api.project.getPatchById(project.id, patch.id);
-
    } catch (error) {
-
      if (error instanceof Error) {
-
        modal.show({
-
          component: ErrorModal,
-
          props: {
-
            title: "Unable to fetch patch",
-
            subtitle: [
-
              "There was an error while refreshing this patch.",
-
              "Check your radicle-httpd logs for details.",
-
            ],
-
            error: {
-
              message: error.message,
-
              stack: error.stack,
-
            },
-
          },
-
        });
-
      }
-
    }
-
  }
-

  function badgeColor(status: string): ComponentProps<Badge>["variant"] {
    if (status === "draft") {
      return "foreground";
@@ -540,11 +143,6 @@
    return patchReviews;
  }

-
  type State = "read" | "submit" | "edit";
-

-
  let patchState: State = "read";
-
  let labelState: State = "read";
-

  let revisionId: string;
  $: if (view.name === "diff") {
    revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -560,9 +158,7 @@
  );
  $: description = patch.revisions[0].description;
  $: lastEdit = patch.revisions[0].edits.at(-1);
-
  $: newDescription = description;
  $: reviews = computeReviews(patch);
-
  $: selectedItem = patch.state.status === "open" ? items[1] : items[0];
  $: timelineTuple = patch.revisions.map<
    [
      {
@@ -615,13 +211,8 @@
        })),
    ].sort((a, b) => a.timestamp - b.timestamp),
  ]);
-
  $: delegates = project.delegates.map(d => d.id);
  $: firstRevision = timelineTuple[0][0];
  $: latestRevision = patch.revisions[patch.revisions.length - 1];
-
  $: session =
-
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
-
      ? $httpdStore.session
-
      : undefined;
</script>

<style>
@@ -692,12 +283,6 @@
    gap: 0.5rem;
    width: 100%;
  }
-
  .connector {
-
    width: 1px;
-
    height: 1.5rem;
-
    margin-left: 1.25rem;
-
    background-color: var(--color-fill-separator);
-
  }
  @media (max-width: 719.98px) {
    .patch {
      display: block;
@@ -720,44 +305,15 @@
        <svelte:fragment slot="title">
          <div
            style="display: flex; align-items: center; gap: 1rem; width: 100%;">
-
            {#if patchState !== "read"}
-
              <TextInput
-
                placeholder="Title"
-
                bind:value={patch.title}
-
                showKeyHint={false} />
-
            {:else if !patch.title}
-
              <span class="txt-missing">No title</span>
-
            {:else}
+
            {#if patch.title}
              <div class="title">
                <InlineTitle fontSize="large" content={patch.title} />
              </div>
+
            {:else}
+
              <span class="txt-missing">No title</span>
            {/if}
          </div>
-
          {#if $experimental && session && role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id) && patchState === "read"}
-
            <div class="global-hide-on-mobile-down">
-
              <Button
-
                variant="outline"
-
                title="edit patch"
-
                on:click={() => (patchState = "edit")}>
-
                <IconSmall name={"edit"} />
-
                Edit
-
              </Button>
-
            </div>
-
          {/if}
-
          {#if patchState === "read"}
-
            <Share {baseUrl} />
-
            {#if $experimental && session && role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id)}
-
              <div class="global-hide-on-small-desktop-down">
-
                <CobStateButton
-
                  items={items.filter(
-
                    ([, state]) => !isEqual(state, patch.state),
-
                  )}
-
                  {selectedItem}
-
                  state={patch.state}
-
                  save={partial(saveStatus, session.id)} />
-
              </div>
-
            {/if}
-
          {/if}
+
          <Share {baseUrl} />
        </svelte:fragment>
        <svelte:fragment slot="state">
          <Badge size="tiny" variant={badgeColor(patch.state.status)}>
@@ -799,52 +355,13 @@
            style:margin-top="2rem"
            style="display: flex; flex-direction: column; gap: 0.5rem;">
            <Reviews {reviews} />
-
            <LabelInput
-
              locallyAuthenticated={role.isDelegate(
-
                session?.publicKey,
-
                delegates,
-
              )}
-
              submitInProgress={labelState === "submit"}
-
              labels={patch.labels}
-
              on:save={({ detail: newLabels }) => {
-
                void saveLabels(newLabels);
-
              }} />
+
            <Labels labels={patch.labels} />
            <Embeds embeds={uniqueEmbeds} />
          </div>
        </div>
        <svelte:fragment slot="description">
          <div class="revision-description">
-
            {#if $experimental && session && patchState !== "read" && lastEdit}
-
              <ExtendedTextarea
-
                isValid={() => patch.title.length > 0}
-
                enableAttachments
-
                embeds={parseEmbedIntoMap(lastEdit.embeds)}
-
                rawPath={rawPath(patch.revisions[0].id)}
-
                body={newDescription}
-
                submitCaption="Save"
-
                submitInProgress={patchState === "submit"}
-
                placeholder="Leave a description"
-
                on:close={() => {
-
                  patchState = "read";
-
                  void refreshPatch();
-
                }}
-
                on:submit={async ({ detail: { comment, embeds } }) => {
-
                  patchState = "submit";
-
                  if (session) {
-
                    try {
-
                      await editPatch(session.id, patch.title);
-
                      await editRevision(
-
                        session.id,
-
                        patch.id,
-
                        comment,
-
                        Array.from(embeds.values()),
-
                      );
-
                    } finally {
-
                      patchState = "read";
-
                    }
-
                  }
-
                }} />
-
            {:else if description}
+
            {#if description}
              <Markdown
                breaks
                content={description}
@@ -852,36 +369,11 @@
            {:else}
              <span class="txt-missing">No description available</span>
            {/if}
-
            {#if ($experimental && session) || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
-
              <div class="actions">
-
                {#if session}
-
                  <div class="global-hide-on-mobile-down">
-
                    <ReactionSelector
-
                      reactions={firstRevision.revisionReactions}
-
                      on:select={async ({ detail: { emoji, authors } }) => {
-
                        if (session) {
-
                          try {
-
                            await reactOnRevision(
-
                              session,
-
                              patch.id,
-
                              authors,
-
                              emoji,
-
                            );
-
                          } finally {
-
                            closeFocused();
-
                          }
-
                        }
-
                      }} />
-
                  </div>
-
                {/if}
-
                {#if firstRevision.revisionReactions.length > 0}
-
                  <Reactions
-
                    handleReaction={session &&
-
                      partial(reactOnRevision, session, patch.id)}
-
                    reactions={firstRevision.revisionReactions} />
-
                {/if}
-
              </div>
-
            {/if}
+
            <div class="actions">
+
              {#if firstRevision.revisionReactions.length > 0}
+
                <Reactions reactions={firstRevision.revisionReactions} />
+
              {/if}
+
            </div>
          </div>
        </svelte:fragment>
      </CobHeader>
@@ -961,62 +453,12 @@
              {timelines}
              {...revision}
              first={index === 0}
-
              canEdit={partial(
-
                role.isDelegateOrAuthor,
-
                session?.publicKey,
-
                delegates,
-
              )}
-
              editRevision={$experimental &&
-
                session &&
-
                partial(editRevision, session.id, revision.revisionId)}
-
              editComment={$experimental &&
-
                session &&
-
                partial(editComment, session.id, revision.revisionId)}
-
              reactOnComment={$experimental &&
-
                session &&
-
                partial(reactOnComment, session, revision.revisionId)}
-
              reactOnRevision={$experimental &&
-
                session &&
-
                partial(reactOnRevision, session, revision.revisionId)}
-
              createReply={$experimental &&
-
                session &&
-
                partial(createReply, session.id, revision.revisionId)}
              patchId={patch.id}
              patchState={patch.state}
              initiallyExpanded={index === patch.revisions.length - 1}
              previousRevId={previousRevision?.id}
              previousRevBase={previousRevision?.base}
-
              previousRevOid={previousRevision?.oid}>
-
              {#if index === patch.revisions.length - 1}
-
                {#if $experimental && session && view.name === "activity"}
-
                  <div class="global-hide-on-mobile-down">
-
                    <div class="connector" />
-
                    <CommentToggleInput
-
                      rawPath={rawPath(patch.revisions[0].id)}
-
                      focus
-
                      enableAttachments
-
                      placeholder="Leave your comment"
-
                      submit={partial(
-
                        createComment,
-
                        session.id,
-
                        revision.revisionId,
-
                      )} />
-
                    {#if role.isDelegateOrAuthor(session.publicKey, delegates, patch.author.id)}
-
                      <div class="connector" />
-
                      <div style="display: flex;">
-
                        <CobStateButton
-
                          items={items.filter(
-
                            ([, state]) => !isEqual(state, patch.state),
-
                          )}
-
                          {selectedItem}
-
                          state={patch.state}
-
                          save={partial(saveStatus, session.id)} />
-
                      </div>
-
                    {/if}
-
                  </div>
-
                {/if}
-
              {/if}
-
            </RevisionComponent>
+
              previousRevOid={previousRevision?.oid} />
          {:else}
            <div style:margin="4rem 0">
              <Placeholder
@@ -1039,13 +481,7 @@

    <div class="metadata global-hide-on-medium-desktop-down">
      <Reviews {reviews} />
-
      <LabelInput
-
        locallyAuthenticated={role.isDelegate(session?.publicKey, delegates)}
-
        submitInProgress={labelState === "submit"}
-
        labels={patch.labels}
-
        on:save={({ detail: newLabels }) => {
-
          void saveLabels(newLabels);
-
        }} />
+
      <Labels labels={patch.labels} />
      <Embeds embeds={uniqueEmbeds} />
    </div>
  </div>
modified src/views/projects/Patch/PatchTeaser.svelte
@@ -4,16 +4,15 @@

  import { absoluteTimestamp, formatTimestamp } from "@app/lib/utils";

+
  import CommentCounter from "../CommentCounter.svelte";
+
  import DiffStatBadgeLoader from "../DiffStatBadgeLoader.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineLabels from "@app/views/projects/Cob/InlineLabels.svelte";
  import InlineTitle from "@app/views/projects/components/InlineTitle.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";

-
  import CommentCounter from "../CommentCounter.svelte";
-
  import DiffStatBadgeLoader from "../DiffStatBadgeLoader.svelte";
-
  import Id from "@app/components/Id.svelte";
-
  import Labels from "../Cob/Labels.svelte";
-

  export let projectId: string;
  export let baseUrl: BaseUrl;
  export let patch: Patch;
@@ -111,7 +110,7 @@
        <span
          class="global-hide-on-small-desktop-down"
          style="display: inline-flex; gap: 0.5rem;">
-
          <Labels labels={patch.labels} />
+
          <InlineLabels labels={patch.labels} />
        </span>
      {/if}
      <div class="right">
@@ -129,7 +128,7 @@
          <div
            class="global-hide-on-medium-desktop-up"
            style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
-
            <Labels labels={patch.labels} />
+
            <InlineLabels labels={patch.labels} />
          </div>
        {/if}
        <div
modified src/views/projects/Patches.svelte
@@ -11,9 +11,7 @@
  import capitalize from "lodash/capitalize";

  import { PATCHES_PER_PAGE } from "./router";
-
  import { experimental } from "@app/lib/appearance";
-
  import { httpdStore } from "@app/lib/httpd";
-
  import { baseUrlToString, isLocal } from "@app/lib/utils";
+
  import { baseUrlToString } from "@app/lib/utils";

  import Button from "@app/components/Button.svelte";
  import DropdownList from "@app/components/DropdownList.svelte";
@@ -28,7 +26,6 @@
  import Placeholder from "@app/components/Placeholder.svelte";
  import Popover, { closeFocused } from "@app/components/Popover.svelte";
  import Share from "./Share.svelte";
-
  import Command from "@app/components/Command.svelte";

  export let baseUrl: BaseUrl;
  export let seedingPolicy: SeedingPolicy;
@@ -108,12 +105,6 @@
    background-color: var(--color-fill-counter);
    color: var(--color-foreground-dim);
  }
-
  .popover {
-
    min-width: 16rem;
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1rem;
-
  }
  .placeholder {
    height: calc(100% - 4rem);
    display: flex;
@@ -180,26 +171,6 @@

    <div style="margin-left: auto; display: flex; gap: 1rem;">
      <Share {baseUrl} />
-
      {#if $experimental && $httpdStore.state === "authenticated" && isLocal(baseUrl.hostname)}
-
        <div class="global-hide-on-mobile-down">
-
          <Popover popoverPositionTop="2.5rem" popoverPositionRight="0">
-
            <Button
-
              slot="toggle"
-
              let:toggle
-
              on:click={toggle}
-
              variant="secondary">
-
              <IconSmall name="plus" />
-
              New Patch
-
            </Button>
-

-
            <div slot="popover" class="popover txt-small">
-
              To create a patch, first checkout a new branch and commit your
-
              changes, then run the following command.
-
              <Command command="git push rad HEAD:refs/patches" />
-
            </div>
-
          </Popover>
-
        </div>
-
      {/if}
    </div>
  </div>

modified src/views/projects/Share.svelte
@@ -1,19 +1,17 @@
<script lang="ts">
-
  import type { BaseUrl } from "@http-client";
+
  import { HttpdClient, type BaseUrl } from "@http-client";

  import debounce from "lodash/debounce";
-
  import { api, httpdStore } from "@app/lib/httpd";
-
  import { isLocal, toClipboard } from "@app/lib/utils";
+
  import { toClipboard } from "@app/lib/utils";
  import config from "virtual:config";

  import Button from "@app/components/Button.svelte";
  import IconSmall from "@app/components/IconSmall.svelte";
-
  import Popover from "@app/components/Popover.svelte";
-
  import ShareButton from "./Header/ShareButton.svelte";

  export let baseUrl: BaseUrl;

-
  const caption = "Link to seed";
+
  const api = new HttpdClient(baseUrl);
+

  let shareIcon: "link" | "checkmark" = "link";
  let loading = false;

@@ -42,32 +40,12 @@
  }
</script>

-
{#if $httpdStore.state !== "stopped" && isLocal(baseUrl.hostname)}
-
  <Popover
-
    popoverPadding="1rem"
-
    popoverPositionTop="2.5rem"
-
    popoverPositionRight="0">
-
    <Button
-
      variant="outline"
-
      size="regular"
-
      slot="toggle"
-
      let:toggle
-
      on:click={toggle}>
-
      <IconSmall name="link" />
-
      <span class="global-hide-on-small-desktop-down">
-
        {caption}
-
      </span>
-
    </Button>
-
    <ShareButton slot="popover" />
-
  </Popover>
-
{:else}
-
  <Button
-
    variant="outline"
-
    size="regular"
-
    on:click={async () => {
-
      await copy();
-
    }}>
-
    <IconSmall name={shareIcon} />
-
    <span class="global-hide-on-small-desktop-down">Copy link</span>
-
  </Button>
-
{/if}
+
<Button
+
  variant="outline"
+
  size="regular"
+
  on:click={async () => {
+
    await copy();
+
  }}>
+
  <IconSmall name={shareIcon} />
+
  <span class="global-hide-on-small-desktop-down">Copy link</span>
+
</Button>
modified src/views/projects/Source.svelte
@@ -29,7 +29,6 @@
  export let seedingPolicy: SeedingPolicy;
  export let rawPath: (commit?: string) => string;
  export let revision: string | undefined;
-
  export let seeding: boolean;
  export let tree: Tree;

  let mobileFileTree = false;
@@ -123,7 +122,7 @@
  {project}
  activeTab="source"
  stylePaddingBottom="0">
-
  <ProjectNameHeader {project} {baseUrl} {seeding} slot="header" />
+
  <ProjectNameHeader {project} {baseUrl} slot="header" />

  <div style:margin="1rem" slot="subheader">
    <Header
modified src/views/projects/Source/ProjectNameHeader.svelte
@@ -10,12 +10,10 @@
  import IconSmall from "@app/components/IconSmall.svelte";
  import Id from "@app/components/Id.svelte";
  import Link from "@app/components/Link.svelte";
-
  import SeedButton from "@app/views/projects/Header/SeedButton.svelte";
  import Share from "@app/views/projects/Share.svelte";

  export let project: Project;
  export let baseUrl: BaseUrl;
-
  export let seeding: boolean;

  function render(content: string): string {
    return dompurify.sanitize(
@@ -89,10 +87,6 @@
        style:gap="0.5rem"
        class="global-hide-on-mobile-down">
        <CloneButton {baseUrl} id={project.id} name={project.name} />
-
        <SeedButton
-
          {seeding}
-
          seedCount={project.seeding}
-
          projectId={project.id} />
      </div>
    </div>
  </div>
modified src/views/projects/error.ts
@@ -1,10 +1,8 @@
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import type { ProjectRoute } from "@app/views/projects/router";

-
import { baseUrlToString, isLocal } from "@app/lib/utils";
+
import { baseUrlToString } from "@app/lib/utils";
import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
-
import { httpdStore } from "@app/lib/httpd";
-
import { get } from "svelte/store";

export function handleError(
  error: Error | ResponseParseError | ResponseError,
@@ -46,21 +44,6 @@ export function handleError(
        description: error.description,
      },
    };
-
  } else if (
-
    error instanceof TypeError &&
-
    error.message === "Failed to fetch" &&
-
    isLocal(route.node.hostname) &&
-
    get(httpdStore).state === "stopped"
-
  ) {
-
    return {
-
      resource: "error",
-
      params: {
-
        title: "Could not load this repository",
-
        description:
-
          "You're trying to access a repository on your local node but the app is not connected to it. Click the Connect button in the top right corner to connect.",
-
        error: undefined,
-
      },
-
    };
  } else {
    return {
      resource: "error",
modified src/views/projects/router.ts
@@ -21,15 +21,12 @@ import type {
  Tree,
} from "@http-client";

-
import { experimental } from "@app/lib/appearance";
-
import * as httpd from "@app/lib/httpd";
import * as Syntax from "@app/lib/syntax";
import { unreachable } from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";
import { handleError, unreachableError } from "@app/views/projects/error";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
-
import { get } from "svelte/store";

export const COMMITS_PER_PAGE = 30;
export const PATCHES_PER_PAGE = 10;
@@ -46,7 +43,6 @@ export type ProjectRoute =
    }
  | ProjectIssuesRoute
  | ProjectIssueRoute
-
  | { resource: "project.newIssue"; node: BaseUrl; project: string }
  | ProjectPatchesRoute
  | ProjectPatchRoute;

@@ -124,7 +120,6 @@ export type ProjectLoadedRoute =
        path: string;
        rawPath: (commit?: string) => string;
        blobResult: BlobResult;
-
        seeding: boolean;
      };
    }
  | {
@@ -139,7 +134,6 @@ export type ProjectLoadedRoute =
        revision: string | undefined;
        tree: Tree;
        commitHeaders: CommitHeader[];
-
        seeding: boolean;
      };
    }
  | {
@@ -172,15 +166,6 @@ export type ProjectLoadedRoute =
      };
    }
  | {
-
      resource: "project.newIssue";
-
      params: {
-
        baseUrl: BaseUrl;
-
        seedingPolicy: SeedingPolicy;
-
        project: Project;
-
        rawPath: (commit?: string) => string;
-
      };
-
    }
-
  | {
      resource: "project.patches";
      params: {
        baseUrl: BaseUrl;
@@ -254,33 +239,11 @@ function parseRevisionToOid(
  }
}

-
async function isLocalNodeSeeding(route: ProjectRoute): Promise<boolean> {
-
  if (!get(experimental) && get(httpd.httpdStore).state === "stopped") {
-
    return false;
-
  }
-
  try {
-
    const policies = await httpd.api.getPolicies();
-
    return policies.some(({ rid }) => rid === route.project);
-
  } catch (error) {
-
    if (error instanceof ResponseError && error.status === 404) {
-
      return false;
-
    }
-

-
    // Either `radicle-httpd` isn't running or there was some other
-
    // error.
-
    return false;
-
  }
-
}
-

export async function loadProjectRoute(
  route: ProjectRoute,
  previousLoaded: LoadedRoute,
): Promise<ProjectLoadedRoute | ErrorRoute | NotFoundRoute> {
  const api = new HttpdClient(route.node);
-
  const rawPath = (commit?: string) =>
-
    `${route.node.scheme}://${route.node.hostname}:${route.node.port}/raw/${
-
      route.project
-
    }${commit ? `/${commit}` : ""}`;

  try {
    if (route.resource === "project.source") {
@@ -309,20 +272,6 @@ export async function loadProjectRoute(
      return await loadPatchView(route);
    } else if (route.resource === "project.issues") {
      return await loadIssuesView(route);
-
    } else if (route.resource === "project.newIssue") {
-
      const [project, seedingPolicy] = await Promise.all([
-
        api.project.getById(route.project),
-
        api.getPoliciesById(route.project),
-
      ]);
-
      return {
-
        resource: "project.newIssue",
-
        params: {
-
          baseUrl: route.node,
-
          seedingPolicy,
-
          project,
-
          rawPath,
-
        },
-
      };
    } else if (route.resource === "project.patches") {
      return await loadPatchesView(route);
    } else {
@@ -425,11 +374,10 @@ async function loadTreeView(
    seedingPolicyPromise = api.getPoliciesById(route.project);
  }

-
  const [project, peers, seedingPolicy, seeding] = await Promise.all([
+
  const [project, peers, seedingPolicy] = await Promise.all([
    projectPromise,
    peersPromise,
    seedingPolicyPromise,
-
    isLocalNodeSeeding(route),
  ]);

  let branchMap: Record<string, string> = {
@@ -477,7 +425,6 @@ async function loadTreeView(
      tree,
      path,
      blobResult,
-
      seeding,
    },
  };
}
@@ -558,14 +505,13 @@ async function loadHistoryView(
    );
  }

-
  const [tree, commitHeaders, seeding] = await Promise.all([
+
  const [tree, commitHeaders] = await Promise.all([
    api.project.getTree(route.project, commitId),
    await api.project.getAllCommits(project.id, {
      parent: commitId,
      page: 0,
      perPage: COMMITS_PER_PAGE,
    }),
-
    isLocalNodeSeeding(route),
  ]);

  return {
@@ -580,7 +526,6 @@ async function loadHistoryView(
      revision: route.revision,
      tree,
      commitHeaders,
-
      seeding,
    },
  };
}
@@ -774,13 +719,7 @@ export function resolveProjectRoute(
    };
  } else if (content === "issues") {
    const issueOrAction = segments.shift();
-
    if (issueOrAction === "new") {
-
      return {
-
        resource: "project.newIssue",
-
        node,
-
        project,
-
      };
-
    } else if (issueOrAction) {
+
    if (issueOrAction) {
      return {
        resource: "project.issue",
        node,
@@ -905,8 +844,6 @@ export function projectRouteToPath(route: ProjectRoute): string {
    return pathSegments.join("/");
  } else if (route.resource === "project.commit") {
    return [...pathSegments, "commits", route.commit].join("/");
-
  } else if (route.resource === "project.newIssue") {
-
    return [...pathSegments, "issues", "new"].join("/");
  } else if (route.resource === "project.issues") {
    let url = [...pathSegments, "issues"].join("/");
    const searchParams = new URLSearchParams();
@@ -977,8 +914,6 @@ export function projectTitle(loadedRoute: ProjectLoadedRoute) {
  } else if (loadedRoute.resource === "project.history") {
    title.push(loadedRoute.params.project.name);
    title.push("history");
-
  } else if (loadedRoute.resource === "project.newIssue") {
-
    title.push("new issue");
  } else if (loadedRoute.resource === "project.issue") {
    title.push(loadedRoute.params.issue.title);
    title.push("issue");
deleted src/views/session/Index.svelte
@@ -1,57 +0,0 @@
-
<script lang="ts">
-
  import type { SessionRoute } from "@app/lib/router/definitions";
-

-
  import { onMount } from "svelte";
-

-
  import * as httpd from "@app/lib/httpd";
-
  import * as modal from "@app/lib/modal";
-
  import * as router from "@app/lib/router";
-
  import { experimental } from "@app/lib/appearance";
-

-
  import AppLayout from "@app/App/AppLayout.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-

-
  import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
-
  import ErrorModal from "@app/modals/ErrorModal.svelte";
-

-
  export let activeRoute: SessionRoute;
-

-
  onMount(async () => {
-
    const port = Number.parseInt(activeRoute.params.apiAddr.split(":")[1]);
-
    if (port > 0 && port < 2 ** 16) {
-
      httpd.changeHttpdPort(port);
-
    }
-
    const isAuthenticated = await httpd.authenticate(activeRoute.params);
-

-
    if (!isAuthenticated && !$experimental) {
-
      modal.show({
-
        component: ErrorModal,
-
        props: {
-
          title: "Authentication failed",
-
          subtitle: [
-
            "Authentication only works in experimental mode.",
-
            "Go to Settings, set experimental mode to On and try again.",
-
          ],
-
          error: {
-
            message: "Can't authenticate in read-only mode.",
-
            stack: undefined,
-
          },
-
        },
-
      });
-
    } else if (!isAuthenticated) {
-
      modal.show({
-
        component: AuthenticationErrorModal,
-
        props: {},
-
      });
-
    }
-

-
    void router.navigateToUrl(
-
      "push",
-
      new URL(activeRoute.params.path || "", window.location.origin),
-
    );
-
  });
-
</script>
-

-
<AppLayout>
-
  <Loading center />
-
</AppLayout>
modified tests/e2e/landingPage.spec.ts
@@ -1,10 +1,10 @@
import { expect, test } from "@tests/support/fixtures.js";

-
test("show pinned projects", async ({ page }) => {
-
  await page.addInitScript(() => localStorage.setItem("experimental", "true"));
+
test("show pinned repositories", async ({ page }) => {
  await page.goto("/");
-
  await expect(page.getByText("Local repositories")).toBeVisible();
-

+
  await page.getByTitle("Switch preferred seeds").getByRole("button").click();
+
  await page.getByPlaceholder("Navigate to seed URL").fill("127.0.0.1");
+
  await page.keyboard.press("Enter");
  // Shows pinned project name.
  await expect(page.getByText("source-browsing")).toBeVisible();
  //
modified tests/e2e/node.spec.ts
@@ -1,13 +1,21 @@
-
import { expect, shortNodeRemote, test } from "@tests/support/fixtures.js";
+
import {
+
  defaultConfig,
+
  expect,
+
  shortNodeRemote,
+
  test,
+
} from "@tests/support/fixtures.js";

test("node metadata", async ({ page, peerManager }) => {
  const peer = await peerManager.createPeer({
    name: "node-metadata-peer",
  });
  await peer.startNode({
-
    seedingPolicy: { default: "allow", scope: "all" },
-
    alias: "palm",
-
    externalAddresses: ["seed.radicle.test:8123"],
+
    node: {
+
      ...defaultConfig.node,
+
      seedingPolicy: { default: "allow", scope: "all" },
+
      alias: "palm",
+
      externalAddresses: ["seed.radicle.test:8123"],
+
    },
  });
  await peer.startHttpd();

modified tests/e2e/project.spec.ts
@@ -56,9 +56,6 @@ test("navigate to project", async ({ page }) => {

  // Show rendered README.md contents.
  await expect(page.getByText("Git test repository")).toBeVisible();
-

-
  // Number of nodes seeding this project.
-
  await expect(page.getByText("Seed 3")).toBeVisible();
});

test("show source tree at specific revision", async ({ page }) => {
modified tests/support/fixtures.ts
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */
+
import type { Config } from "@http-client";
+
import type { PeerManager, RadiclePeer } from "./peerManager.js";
import type * as Stream from "node:stream";

import * as Fs from "node:fs/promises";
@@ -13,7 +15,6 @@ import * as patch from "@tests/support/cobs/patch.js";
import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
import { createPeerManager } from "@tests/support/peerManager.js";
import { createProject } from "@tests/support/project.js";
-
import type { PeerManager, RadiclePeer } from "./peerManager.js";
import { formatCommit } from "@app/lib/utils.js";

export { expect };
@@ -124,10 +125,7 @@ export const test = base.extend<{
    await peerManager.shutdown();
  },

-
  peer: async ({ page, peerManager }, use) => {
-
    await page.addInitScript(() => {
-
      window.localStorage.setItem("experimental", "true");
-
    });
+
  peer: async ({ peerManager }, use) => {
    const peer = await peerManager.createPeer({
      name: "httpd",
      gitOptions: gitOptions["bob"],
@@ -191,8 +189,16 @@ export async function createSourceBrowsingFixture(
    gitOptions: gitOptions["bob"],
  });
  const bobProjectPath = Path.join(bob.checkoutPath, "source-browsing");
-
  await alice.startNode({ connect: [palm.address], alias: "alice" });
-
  await bob.startNode({ connect: [palm.address], alias: "bob" });
+
  await alice.startNode({
+
    node: {
+
      ...defaultConfig.node,
+
      connect: [palm.address],
+
      alias: "alice",
+
    },
+
  });
+
  await bob.startNode({
+
    node: { ...defaultConfig.node, connect: [palm.address], alias: "bob" },
+
  });
  await palm.waitForEvent({ type: "peerConnected", nid: alice.nodeId }, 1000);
  await palm.waitForEvent({ type: "peerConnected", nid: bob.nodeId }, 1000);

@@ -622,3 +628,55 @@ export const gitOptions = {
    GIT_COMMITTER_DATE: "1671627600",
  },
};
+
export const defaultConfig: Config = {
+
  publicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path",
+
  preferredSeeds: [],
+
  web: {
+
    pinned: {
+
      repositories: ["rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir"],
+
    },
+
    imageUrl: "https://docs.radicle.xyz/assets/images/banners/16.medium.png",
+
    name: "My fancy garden node",
+
    description: "Bees live here",
+
  },
+
  cli: {
+
    hints: true,
+
  },
+
  node: {
+
    alias: "alice",
+
    listen: [],
+
    peers: {
+
      type: "dynamic",
+
    },
+
    connect: [],
+
    externalAddresses: [],
+
    network: "test",
+
    log: "INFO",
+
    relay: "auto",
+
    limits: {
+
      routingMaxSize: 1000,
+
      routingMaxAge: 604800,
+
      gossipMaxAge: 1209600,
+
      fetchConcurrency: 1,
+
      maxOpenFiles: 4096,
+
      rate: {
+
        inbound: {
+
          fillRate: 2.0,
+
          capacity: 128,
+
        },
+
        outbound: {
+
          fillRate: 5.0,
+
          capacity: 256,
+
        },
+
      },
+
      connection: {
+
        inbound: 128,
+
        outbound: 16,
+
      },
+
    },
+
    workers: 32,
+
    seedingPolicy: {
+
      default: "block",
+
    },
+
  },
+
};
modified tests/support/globalSetup.ts
@@ -6,6 +6,7 @@ import {
  tmpDir,
} from "@tests/support/support.js";
import {
+
  defaultConfig,
  createCobsFixture,
  createMarkdownFixture,
  createSourceBrowsingFixture,
@@ -51,8 +52,16 @@ export default async function globalSetup(): Promise<() => void> {

  if (!process.env.SKIP_FIXTURE_CREATION) {
    await palm.startNode({
-
      seedingPolicy: { default: "allow", scope: "all" },
-
      alias: "palm",
+
      web: {
+
        pinned: {
+
          repositories: ["rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir"],
+
        },
+
      },
+
      node: {
+
        ...defaultConfig.node,
+
        seedingPolicy: { default: "allow", scope: "all" },
+
        alias: "palm",
+
      },
    });
    await palm.startHttpd(defaultHttpdPort);

modified tests/support/peerManager.ts
@@ -1,20 +1,22 @@
/* eslint-disable @typescript-eslint/naming-convention */
-
import type { BaseUrl } from "@http-client";
+
import type { Config, BaseUrl } from "@http-client";
import type * as Execa from "execa";
-
import { execa } from "execa";
+

import * as Fs from "node:fs/promises";
import * as Os from "node:os";
import * as Path from "node:path";
import * as Stream from "node:stream";
import * as Util from "node:util";
+
import * as readline from "node:readline/promises";
import getPort from "get-port";
import matches from "lodash/matches.js";
import waitOn from "wait-on";
-
import * as readline from "node:readline/promises";
+
import { configSchema } from "@http-client/lib/shared.js";
+
import { defaultConfig } from "@tests/support/fixtures.js";
+
import { execa } from "execa";
+
import { logPrefix } from "@tests/support/logPrefix.js";
import { randomTag } from "@tests/support/support.js";
import { sleep } from "@app/lib/sleep.js";
-
import { array, literal, number, object, string, union, z } from "zod";
-
import { logPrefix } from "./logPrefix.js";

export type RefsUpdate =
  | { updated: { name: string; old: string; new: string } }
@@ -117,58 +119,6 @@ export async function createPeerManager(createParams: {
  };
}

-
export const NodeConfigSchema = object({
-
  publicExplorer: string(),
-
  preferredSeeds: array(string()),
-
  node: object({
-
    alias: string(),
-
    peers: union([
-
      object({ type: literal("static") }),
-
      object({ type: literal("dynamic") }),
-
    ]),
-
    connect: array(string()),
-
    externalAddresses: array(string()),
-
    proxy: string().optional(),
-
    onion: union([
-
      object({
-
        mode: literal("proxy"),
-
        address: string(),
-
      }),
-
      object({ mode: literal("forward") }),
-
    ]).optional(),
-
    log: union([
-
      literal("ERROR"),
-
      literal("WARN"),
-
      literal("INFO"),
-
      literal("DEBUG"),
-
      literal("TRACE"),
-
    ]),
-
    network: union([literal("main"), literal("test")]),
-
    relay: union([literal("always"), literal("never"), literal("auto")]),
-
    limits: object({
-
      routingMaxSize: number(),
-
      routingMaxAge: number(),
-
      gossipMaxAge: number(),
-
      fetchConcurrency: number(),
-
      maxOpenFiles: number(),
-
      rate: object({
-
        inbound: object({ fillRate: number(), capacity: number() }),
-
        outbound: object({ fillRate: number(), capacity: number() }),
-
      }),
-
      connection: object({
-
        inbound: number(),
-
        outbound: number(),
-
      }),
-
    }),
-
    seedingPolicy: object({
-
      default: union([literal("allow"), literal("block")]),
-
      scope: union([literal("followed"), literal("all")]).optional(),
-
    }),
-
  }),
-
});
-

-
export interface NodeConfig extends z.infer<typeof NodeConfigSchema> {}
-

// Specialize the return type of `execa()` to guarantee that `stdout` and
// `stderr` are strings.
type SpawnResult = Execa.ResultPromise<
@@ -303,11 +253,11 @@ export class RadiclePeer {
    });
  }

-
  public async startNode(nodeParams: Partial<NodeConfig["node"]> = {}) {
+
  public async startNode(config: Partial<Config> = defaultConfig) {
    const listenPort = await getPort();
    this.#listenSocketAddr = `0.0.0.0:${listenPort}`;

-
    await updateNodeConfig(this.#radHome, nodeParams);
+
    await updateConfig(this.#radHome, config);

    this.#nodeProcess = this.spawn("radicle-node", [
      "--listen",
@@ -452,18 +402,18 @@ export class RadiclePeer {
  }
}

-
async function updateNodeConfig(
-
  radHome: string,
-
  nodeParams: Partial<NodeConfig["node"]>,
-
) {
+
async function updateConfig(radHome: string, configParams: Partial<Config>) {
  const configPath = Path.join(radHome, "config.json");
  const configFile = await Fs.readFile(configPath, "utf-8");
-
  const config = NodeConfigSchema.parse(JSON.parse(configFile));
+
  const config = configSchema.parse({
+
    defaultConfig,
+
    ...JSON.parse(configFile),
+
  });
  config.preferredSeeds = [];
+
  config.web = { ...config.web, ...configParams.web };
  config.node = {
    ...config.node,
-
    network: "test",
-
    ...nodeParams,
+
    ...configParams.node,
  };
  await Fs.writeFile(configPath, JSON.stringify(config), "utf-8");
}
modified tests/unit/router.test.ts
@@ -122,14 +122,6 @@ describe("route invariant when parsed", () => {
    });
  });

-
  test("projects.newIssue", () => {
-
    expectParsingInvariant({
-
      resource: "project.newIssue",
-
      node,
-
      project: "PROJECT",
-
    });
-
  });
-

  test("projects.issue", () => {
    expectParsingInvariant({
      resource: "project.issue",