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

radicle-httpd

  • Removes session endpoints.
  • Removes put, patch and post endpoints for issues and patches.
  • Removes all tests related to removed endpoints.

radicle-explorer

  • Removed the delegate badge from the ProjectCard component since we don’t check the local httpd anymore.
  • Added the id field to the patch.review schema.
    • Allows to remove the undefined type from the id prop in the Comment component.
  • Simplified the /App/Header component since there is no right side anymore.
  • Removed the strictEvents attribute from the Button component, since we only forward eventlisteners.
  • Did also some changes to the updateNodeConfig fn and the schemas and types it uses, to make it cleaner, and allows us to use not only the node config but also the web and cli config.
  • Removed the SeedButton component and moved the seed count to the ContextRepo component.
  • Removed the isLocal util function and removed every case where we treated a local httpd instance different than a remote one.
  • Removed the special case in the isDomain util function to also allow a loopback address if not in production.

check check-visual check-unit-test check-http-client-unit-test check-radicle-httpd check-e2e check-build check-http

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

96 files changed +517 -7161 16693451 β†’ 41b1bcaf
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/project/patch.ts
@@ -50,6 +50,7 @@ export type Verdict = "accept" | "reject";

const reviewSchema = object({
  author: authorSchema,
+
  id: string(),
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
  comments: array(commentSchema),
  summary: string().nullable(),
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
@@ -97,10 +97,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 {
@@ -135,7 +131,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/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;
@@ -1223,7 +907,7 @@ mod routes {
    async fn test_search_projects() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/search?q=hello")).await;
+
        let response = get(&app, "/projects/search?q=hello").await;

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
@@ -1263,7 +947,7 @@ mod routes {
    async fn test_search_projects_pagination() {
        let tmp = tempfile::tempdir().unwrap();
        let app = super::router(seed(tmp.path()));
-
        let response = get(&app, format!("/projects/search?q=hello&perPage=1")).await;
+
        let response = get(&app, "/projects/search?q=hello&perPage=1").await;

        assert_eq!(response.status(), StatusCode::OK);
        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" });
@@ -63,11 +60,9 @@
    <Loading />
  </div>
{:else if $activeRouteStore.resource === "home"}
-
  <Home {...$activeRouteStore.params} />
+
  <Home />
{: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,86 +1,34 @@
<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>
  header {
    display: flex;
-
    justify-content: space-between;
    align-items: center;
+
    gap: 0.5rem;
    margin: 0;
    padding: 0.5rem 1rem;
    height: 3.5rem;
  }
-
  .left,
-
  .right {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }

  .logo {
    height: var(--button-regular-height);
    margin: 0 0.5rem;
  }
-
  .connect-popover {
-
    max-width: 20rem;
-
  }
</style>

<header>
-
  <div class="left">
-
    <Link
-
      style="display: flex; align-items: center;"
-
      route={{ resource: "home" }}>
-
      <img
-
        width="24"
-
        height="24"
-
        class="logo"
-
        alt="Radicle logo"
-
        src="/radicle.svg" />
-
    </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>
+
  <Link
+
    style="display: flex; align-items: center;"
+
    route={{ resource: "home" }}>
+
    <img
+
      width="24"
+
      height="24"
+
      class="logo"
+
      alt="Radicle logo"
+
      src="/radicle.svg" />
+
  </Link>
+
  <Breadcrumbs />
</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 />
+
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} />
  </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/NodeSegment.svelte
@@ -1,13 +1,10 @@
<script lang="ts">
  import type { BaseUrl } from "@http-client";

-
  import { isLocal } from "@app/lib/utils";
-

  import IconSmall from "@app/components/IconSmall.svelte";
  import Link from "@app/components/Link.svelte";

  export let baseUrl: BaseUrl;
-
  export let showLocalNode: boolean = false;
</script>

<style>
@@ -18,7 +15,6 @@

<span class="segment">
  <Link
-
    title={isLocal(baseUrl.hostname) ? "Local Node" : undefined}
    style="display: flex; align-items: center; gap: 0.25rem;"
    route={{
      resource: "nodes",
@@ -27,14 +23,7 @@
        projectPageIndex: 0,
      },
    }}>
-
    {#if isLocal(baseUrl.hostname)}
-
      <IconSmall name="device" />
-
      {#if showLocalNode}
-
        Local Node
-
      {/if}
-
    {:else}
-
      <IconSmall name="seedling" />
-
      {baseUrl.hostname}
-
    {/if}
+
    <IconSmall name="seedling" />
+
    {baseUrl.hostname}
  </Link>
</span>
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 id: string;
  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;
@@ -139,9 +109,7 @@
      <slot class="icon" name="icon" />
      <NodeId nodeId={authorId} alias={authorAlias} />
      <slot name="caption">{caption}</slot>
-
      {#if id}
-
        <Id {id} />
-
      {/if}
+
      <Id {id} />
      <span class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
        {utils.formatTimestamp(timestamp)}
      </span>
@@ -155,69 +123,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 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/ExternalLink.svelte
@@ -1,3 +1,5 @@
+
<svelte:options customElement="radicle-external-link" />
+

<script lang="ts">
  import IconSmall from "./IconSmall.svelte";

@@ -7,6 +9,7 @@
<style>
  a {
    font-weight: var(--font-weight-semibold);
+
    color: inherit;
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
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,45 +1,4 @@
-
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> = {
+
export const mimes: Record<string, string> = {
  "3gp": "video/3gpp",
  "7z": "application/x-7z-compressed",
  aac: "audio/aac",
@@ -101,12 +60,3 @@ const mimes: Record<string, string> = {
  xml: "application/xml",
  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 };
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();
-
}
modified src/lib/markdown.ts
@@ -88,7 +88,7 @@ export default new Marked();
export const markdownWithExtensions = new Marked(
  katexMarkedExtension({ throwOnError: false }),
  markedLinkifyIt({}, { fuzzyLink: false }),
-
  markedFootnote(),
+
  markedFootnote({ refMarkers: true }),
  markedEmoji({ emojis }),
  ((): MarkedExtension => ({
    extensions: [anchorMarkedExtension],
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,20 +121,12 @@ 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"
  ) {
    title.push(...projectTitle(loadedRoute));
  } else if (loadedRoute.resource === "nodes") {
-
    title.push(
-
      utils.isLocal(loadedRoute.params.baseUrl.hostname)
-
        ? "Local Node"
-
        : loadedRoute.params.baseUrl.hostname,
-
    );
-
  } else if (loadedRoute.resource === "session") {
-
    title.push("Authenticating");
-
    title.push("Radicle");
+
    title.push(loadedRoute.params.baseUrl.hostname);
  } else {
    utils.unreachable(loadedRoute);
  }
@@ -205,22 +197,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 +209,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 +216,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
@@ -8,8 +8,9 @@ import type {
  ProjectRoute,
} from "@app/views/projects/router";
import type { NodesRoute, NodesLoadedRoute } from "@app/views/nodes/router";
+
import type { ComponentProps } from "svelte";
+
import type Icon from "@app/components/Icon.svelte";

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

@@ -22,17 +23,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 {
@@ -40,7 +30,8 @@ export interface ErrorRoute {
  params: {
    title: string;
    description: string;
-
    error: ErrorParam;
+
    error?: ErrorParam;
+
    icon?: ComponentProps<Icon>["name"];
  };
}

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

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

export async function loadRoute(
  route: Route,
@@ -69,13 +58,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" };
  } 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"
modified src/lib/utils.ts
@@ -8,11 +8,6 @@ export async function toClipboard(text: string): Promise<void> {
  await navigator.clipboard.writeText(text);
}

-
export function formatLocationHash(hash: string | null): number | null {
-
  if (hash && hash.match(/^#L[0-9]+$/)) return parseInt(hash.slice(2));
-
  return null;
-
}
-

// Removes the first and last character which are always `/`.
export function formatUserAgent(agent: string): string {
  return agent.slice(1, -1);
@@ -62,14 +57,6 @@ export function parseRepositoryId(
  return undefined;
}

-
export function isNodeId(input: string): boolean {
-
  return Boolean(parseNodeId(input));
-
}
-

-
export function isRepositoryId(input: string): boolean {
-
  return Boolean(parseRepositoryId(input));
-
}
-

export function formatNodeId(id: string): string {
  const parsedId = parseNodeId(id);

@@ -111,19 +98,6 @@ export function baseUrlToString(baseUrl: BaseUrl): string {
  return `${baseUrl.scheme}://${baseUrl.hostname}:${baseUrl.port}`;
}

-
// Generates a publicly shareable link.
-
export function formatPublicExplorer(
-
  publicExplorer: string,
-
  host: string,
-
  rid: string,
-
  fullPath: string,
-
) {
-
  return publicExplorer
-
    .replace("$host", host)
-
    .replace("$rid", rid)
-
    .replace("$path", fullPath.replace(`/nodes/${host}/${rid}`, ""));
-
}
-

// Takes a path, eg. "../images/image.png", and a base from where to start
// resolving, e.g. "static/images/index.html". Returns the resolved path.
export function canonicalize(
@@ -149,13 +123,6 @@ export function canonicalize(
  return pathname;
}

-
// Takes a URL, eg. "https://twitter.com/cloudhead", and return "cloudhead".
-
// Returns the original string if it was unable to extract the username.
-
export function parseUsername(input: string): string {
-
  const parts = input.split("/");
-
  return parts[parts.length - 1];
-
}
-

export function absoluteTimestamp(time: number | undefined) {
  return time ? new Date(time * 1000).toString() : undefined;
}
@@ -219,12 +186,6 @@ export function isSvgPath(input: string): boolean {
  return /\.svg$/i.test(input);
}

-
export function isFulfilled<T>(
-
  input: PromiseSettledResult<T>,
-
): input is PromiseFulfilledResult<T> {
-
  return input.status === "fulfilled";
-
}
-

// Get amount of days passed between two dates without including the end date
export function getDaysPassed(from: Date, to: Date): number {
  return Math.floor((to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000));
@@ -235,7 +196,7 @@ export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
  if (lineElement) lineElement.scrollIntoView(options);
}

-
export function isMac() {
+
function isMac() {
  if (
    (navigator.platform && navigator.platform.includes("Mac")) ||
    navigator.userAgent.includes("OS X")
@@ -255,15 +216,6 @@ export function isMarkdownPath(path: string): boolean {
  return /\.(md|mkd|markdown)$/i.test(path);
}

-
// Check whether the given input string is a domain, eg. seed.radicle.xyz.
-
// Also accepts in dev env 0.0.0.0 as domain.
-
export function isDomain(input: string): boolean {
-
  return (
-
    (/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
-
    (!import.meta.env.PROD && isLocal(input))
-
  );
-
}
-

// Check whether the given address is a localhost address.
export function isLocal(addr: string): boolean {
  return (
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/error/View.svelte
@@ -1,9 +1,12 @@
<script lang="ts">
+
  import type { ComponentProps } from "svelte";
+
  import type Icon from "@app/components/Icon.svelte";
  import type { ErrorParam } from "@app/lib/router/definitions";

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

+
  export let icon: ComponentProps<Icon>["name"] = "desert";
  export let title: string;
  export let description: string;
  export let error: ErrorParam = undefined;
@@ -32,7 +35,7 @@
<AppLayout>
  <div class="wrapper">
    <div class="container">
-
      <ErrorMessage icon="desert" {title} {description} {error} />
+
      <ErrorMessage {icon} {title} {description} {error} />
    </div>
  </div>
</AppLayout>
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,5 @@
-
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: [] },
-
  };
-
}
+
export interface HomeLoadedRoute extends HomeRoute {}
modified src/views/nodes/View.svelte
@@ -4,16 +4,9 @@
  import { capitalize } from "lodash";

  import * as router from "@app/lib/router";
-
  import { api, httpdStore } from "@app/lib/httpd";
-
  import {
-
    baseUrlToString,
-
    formatUserAgent,
-
    isLocal,
-
    truncateId,
-
  } from "@app/lib/utils";
+
  import { baseUrlToString, formatUserAgent, truncateId } 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";
@@ -35,11 +28,6 @@
    seedingPolicy?.default === "allow" && seedingPolicy?.scope === "all"
      ? "permissive"
      : "restrictive";
-
  $: hostname = isLocal(baseUrl.hostname) ? "Local Node" : baseUrl.hostname;
-
  $: session =
-
    $httpdStore.state === "authenticated" && isLocal(api.baseUrl.hostname)
-
      ? $httpdStore.session
-
      : undefined;
</script>

<style>
@@ -128,7 +116,7 @@
  <div class="layout">
    <div class="wrapper">
      <div class="header">
-
        <div class="txt-large txt-bold">{hostname}</div>
+
        <div class="txt-large txt-bold">{baseUrl.hostname}</div>
        <div class="info">
          <div>
            {#each externalAddresses as address}
@@ -163,9 +151,7 @@
      </div>

      <div class="subtitle" style:justify-content="space-between">
-
        <div class="txt-semibold">
-
          {isLocal(baseUrl.hostname) ? "Seeded" : "Pinned"} repositories
-
        </div>
+
        <div class="txt-semibold">Pinned repositories</div>
        <div class="seeding-policy">
          {#if seedingPolicy}
            <span class="txt-bold">Seeding Policy:</span>
@@ -186,7 +172,7 @@
      </div>

      <div style:margin-top="1rem" style:padding-bottom="2.5rem">
-
        {#await fetchProjectInfos( baseUrl, { show: isLocal(baseUrl.hostname) ? "all" : "pinned", perPage: stats.repos.total }, )}
+
        {#await fetchProjectInfos( baseUrl, { show: "pinned", perPage: stats.repos.total }, )}
          <div style:height="35vh">
            <Loading small center />
          </div>
@@ -194,12 +180,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 +192,7 @@
            </div>
          {/if}
        {:catch error}
-
          {router.push(handleError(error, baseUrlToString(api.baseUrl)))}
+
          {router.push(handleError(error, baseUrlToString(baseUrl)))}
        {/await}
      </div>
    </div>
modified src/views/nodes/error.ts
@@ -1,4 +1,5 @@
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
+

import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";

export function handleError(
modified src/views/nodes/router.ts
@@ -4,7 +4,7 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import config from "virtual:config";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
-
import { baseUrlToString } from "@app/lib/utils";
+
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/projects/error";

@@ -43,6 +43,19 @@ export function nodePath(baseUrl: BaseUrl) {
export async function loadNodeRoute(
  params: NodesRouteParams,
): Promise<NodesLoadedRoute | NotFoundRoute | ErrorRoute> {
+
  if (
+
    import.meta.env.PROD &&
+
    isLocal(`${params.baseUrl.hostname}:${params.baseUrl.port}`)
+
  ) {
+
    return {
+
      resource: "error",
+
      params: {
+
        icon: "device",
+
        title: "Local node browsing not supported",
+
        description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
+
      },
+
    };
+
  }
  const api = new HttpdClient(params.baseUrl);
  try {
    const [node, stats] = await Promise.all([api.getNode(), api.getStats()]);
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>
modified src/views/projects/Cob/Embeds.svelte
@@ -1,4 +1,4 @@
-
<script lang="ts" strictEvents>
+
<script lang="ts">
  import type { Embed } from "@http-client";

  import Badge from "@app/components/Badge.svelte";
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
@@ -436,38 +405,8 @@
                β€’ edited
              </div>
            {/if}
-
            <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,27 +414,9 @@
                content={revisionDescription} />
            </div>
          {/if}
-
          {#if reactOnRevision || revisionReactions.length > 0}
+
          {#if revisionReactions && 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}
+
              <Reactions reactions={revisionReactions} />
            </div>
          {/if}
        </div>
@@ -534,14 +455,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">
@@ -575,6 +489,7 @@
            class:positive-review={review.verdict === "accept"}
            class:negative-review={review.verdict === "reject"}>
            <CommentComponent
+
              id={review.id}
              rawPath={rawPath(revisionBase)}
              authorId={author}
              authorAlias={review.author.alias}
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,46 +128,15 @@
      <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} />
-
              </div>
-
            {/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>
+
                <InlineTitle fontSize="large" content={issue.title} />
              </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}
+
            {:else}
+
              <span class="txt-missing">No title</span>
            {/if}
          </div>
+
          <Share {baseUrl} />
        </svelte:fragment>
        <svelte:fragment slot="state">
          {#if issue.state.status === "open"}
@@ -570,62 +172,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 +187,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 +198,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";
@@ -76,6 +74,11 @@
</script>

<style>
+
  .header {
+
    display: flex;
+
    justify-content: space-between;
+
    padding: 1rem;
+
  }
  .more {
    margin-top: 2rem;
    min-height: 3rem;
@@ -113,7 +116,7 @@
</style>

<Layout {seedingPolicy} {baseUrl} {project} activeTab="issues">
-
  <div slot="header" style:display="flex" style:padding="1rem">
+
  <div slot="header" class="header">
    <Popover
      popoverPadding="0"
      popoverPositionTop="2.5rem"
@@ -164,24 +167,7 @@
      </DropdownList>
    </Popover>

-
    <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>
+
    <Share {baseUrl} />
  </div>

  <List items={allIssues}>
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>
@@ -662,12 +253,6 @@
    padding: 1rem 1rem 0.5rem 1rem;
    height: 100%;
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 0.5rem;
-
  }
  .tabs {
    font-size: var(--font-size-tiny);
    display: flex;
@@ -692,12 +277,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;
@@ -718,46 +297,14 @@
    <div class="main">
      <CobHeader>
        <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}
-
              <div class="title">
-
                <InlineTitle fontSize="large" content={patch.title} />
-
              </div>
-
            {/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>
+
          {#if patch.title}
+
            <div class="title">
+
              <InlineTitle fontSize="large" content={patch.title} />
            </div>
+
          {:else}
+
            <span class="txt-missing">No title</span>
          {/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 +346,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,35 +360,8 @@
            {: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 firstRevision.revisionReactions.length > 0}
+
              <Reactions reactions={firstRevision.revisionReactions} />
            {/if}
          </div>
        </svelte:fragment>
@@ -961,62 +442,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 +470,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;
@@ -85,6 +82,11 @@
</script>

<style>
+
  .header {
+
    display: flex;
+
    justify-content: space-between;
+
    padding: 1rem;
+
  }
  .more {
    margin-top: 2rem;
    min-height: 3rem;
@@ -108,12 +110,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;
@@ -128,7 +124,7 @@
</style>

<Layout {seedingPolicy} {baseUrl} {project} activeTab="patches">
-
  <div slot="header" style:display="flex" style:padding="1rem">
+
  <div slot="header" class="header">
    <Popover
      popoverPadding="0"
      popoverPositionTop="2.5rem"
@@ -178,29 +174,7 @@
      </DropdownList>
    </Popover>

-
    <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>
+
    <Share {baseUrl} />
  </div>

  <List items={allPatches}>
modified src/views/projects/Share.svelte
@@ -1,19 +1,16 @@
<script lang="ts">
  import 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 config from "virtual:config";
+
  import debounce from "lodash/debounce";
+
  import { HttpdClient } from "@http-client";
+
  import { toClipboard } from "@app/lib/utils";

  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";
  let shareIcon: "link" | "checkmark" = "link";
  let loading = false;

@@ -21,6 +18,8 @@
    shareIcon = "link";
  }, 1000);

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

  async function copy() {
    if (loading) {
      return;
@@ -42,32 +41,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/Sidebar.svelte
@@ -239,6 +239,7 @@
  <div class="bottom">
    <div class="repo box" class:expanded>
      <ContextRepo
+
        projectSeeds={project.seeding}
        projectThreshold={project.threshold}
        projectDelegates={project.delegates}
        {seedingPolicy} />
@@ -285,6 +286,7 @@

        <div slot="popover" class="txt-small" style:width="18rem">
          <ContextRepo
+
            projectSeeds={project.seeding}
            projectThreshold={project.threshold}
            projectDelegates={project.delegates}
            {seedingPolicy} />
modified src/views/projects/Sidebar/ContextRepo.svelte
@@ -9,6 +9,7 @@

  export let projectThreshold: number;
  export let projectDelegates: Project["delegates"];
+
  export let projectSeeds: number;
  export let seedingPolicy: SeedingPolicy;

  let delegateExpanded = false;
@@ -34,6 +35,12 @@
  }
</style>

+
<div class="item-header" style:height="2rem">
+
  <span>Seeds</span>
+
  <div class="global-flex-item txt-bold" style:padding-right="2.25rem">
+
    {projectSeeds}
+
  </div>
+
</div>
<div class="item-header">
  <span>Delegates</span>
  <div class="global-flex-item">
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,13 @@ 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 config from "virtual:config";
+
import { isLocal, 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 +44,6 @@ export type ProjectRoute =
    }
  | ProjectIssuesRoute
  | ProjectIssueRoute
-
  | { resource: "project.newIssue"; node: BaseUrl; project: string }
  | ProjectPatchesRoute
  | ProjectPatchRoute;

@@ -124,7 +121,6 @@ export type ProjectLoadedRoute =
        path: string;
        rawPath: (commit?: string) => string;
        blobResult: BlobResult;
-
        seeding: boolean;
      };
    }
  | {
@@ -139,7 +135,6 @@ export type ProjectLoadedRoute =
        revision: string | undefined;
        tree: Tree;
        commitHeaders: CommitHeader[];
-
        seeding: boolean;
      };
    }
  | {
@@ -172,15 +167,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 +240,24 @@ 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> {
+
  if (
+
    import.meta.env.PROD &&
+
    isLocal(`${route.node.hostname}:${route.node.port}`)
+
  ) {
+
    return {
+
      resource: "error",
+
      params: {
+
        icon: "device",
+
        title: "Local node browsing not supported",
+
        description: `You're trying to access a repository on a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
+
      },
+
    };
+
  }
  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 +286,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 +388,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 +439,6 @@ async function loadTreeView(
      tree,
      path,
      blobResult,
-
      seeding,
    },
  };
}
@@ -558,14 +519,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 +540,6 @@ async function loadHistoryView(
      revision: route.revision,
      tree,
      commitHeaders,
-
      seeding,
    },
  };
}
@@ -774,13 +733,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 +858,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 +928,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,14 @@
import { expect, test } from "@tests/support/fixtures.js";

-
test("show pinned projects", async ({ page }) => {
-
  await page.addInitScript(() => localStorage.setItem("experimental", "true"));
-
  await page.goto("/");
-
  await expect(page.getByText("Local repositories")).toBeVisible();
+
test("show pinned repositories", async ({ context, page }) => {
+
  await context.addInitScript(() => {
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
+
    );
+
  });

+
  await page.goto("/");
  // Shows pinned project name.
  await expect(page.getByText("source-browsing")).toBeVisible();
  //
modified tests/e2e/node.spec.ts
@@ -1,19 +1,27 @@
-
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();

  await page.goto(peer.uiUrl());

-
  await expect(page.getByRole("link", { name: "Local Node" })).toBeVisible();
+
  await expect(page.getByRole("link", { name: "127.0.0.1" })).toBeVisible();
  await expect(
    page.getByText(`${shortNodeRemote}@seed.radicle.test:8123`),
  ).toBeVisible();
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,52 @@ 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"],
+
    },
+
  },
+
  cli: {
+
    hints: true,
+
  },
+
  node: {
+
    alias: "alice",
+
    listen: [],
+
    peers: {
+
      type: "dynamic",
+
    },
+
    connect: [],
+
    externalAddresses: [],
+
    network: "main",
+
    log: "INFO",
+
    relay: "auto",
+
    limits: {
+
      routingMaxSize: 1000,
+
      routingMaxAge: 604800,
+
      gossipMaxAge: 1209600,
+
      fetchConcurrency: 1,
+
      maxOpenFiles: 4096,
+
      rate: {
+
        inbound: {
+
          fillRate: 5.0,
+
          capacity: 1024,
+
        },
+
        outbound: {
+
          fillRate: 10.0,
+
          capacity: 2048,
+
        },
+
      },
+
      connection: {
+
        inbound: 128,
+
        outbound: 16,
+
      },
+
    },
+
    workers: 8,
+
    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,19 @@ 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,
+
    ...configParams.node,
    network: "test",
-
    ...nodeParams,
  };
  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",
modified tests/unit/utils.test.ts
@@ -3,13 +3,6 @@ import * as utils from "@app/lib/utils";

describe("Format functions", () => {
  test.each([
-
    { hash: "#L42", expected: 42 },
-
    { hash: "#ETH", expected: null },
-
  ])("formatLocationHash $hash => $expected", ({ hash, expected }) => {
-
    expect(utils.formatLocationHash(hash)).toEqual(expected);
-
  });
-

-
  test.each([
    {
      id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      expected: "rad:zKtT7D…19WzjT",
@@ -71,14 +64,6 @@ describe("Format functions", () => {

describe("String Assertions", () => {
  test.each([
-
    { domain: "alt-clients.radicle.xyz", expected: true },
-
    { domain: "0.0.0.0", expected: true }, // Pass as true since we are not in production
-
    { domain: "", expected: false },
-
  ])("isDomain $domain => $expected", ({ domain, expected }) => {
-
    expect(utils.isDomain(domain)).toEqual(expected);
-
  });
-

-
  test.each([
    { path: "README.md", expected: true },
    { path: "README.mkd", expected: true },
    { path: "README.markdown", expected: true },
@@ -88,43 +73,6 @@ describe("String Assertions", () => {
  });

  test.each([
-
    { id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: true },
-
    { id: "z2H9aHDurxd8Uvx2jsvW4e5mamy5S", expected: true },
-
    { id: "rad:BBBBBBBBBBBBBBBBBBBBBBBBBBBB", expected: false },
-
    { id: "0x1234567890123456789012345678901234567890", expected: false },
-
    {
-
      id: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
-
      expected: false,
-
    },
-
    {
-
      id: "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
-
      expected: false,
-
    },
-
  ])("isRepositoryId $id => $expected", ({ id, expected }) => {
-
    expect(utils.isRepositoryId(id)).toEqual(expected);
-
  });
-

-
  test.each([
-
    {
-
      id: "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
-
      expected: true,
-
    },
-
    {
-
      id: "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
-
      expected: true,
-
    },
-
    {
-
      id: "did:key:CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
-
      expected: false,
-
    },
-
    { id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: false },
-
    { id: "z2H9aHDurxd8Uvx2jsvW4e5mamy5S", expected: false },
-
    { id: "0x1234567890123456789012345678901234567890", expected: false },
-
  ])("isNodeId $id => $expected", ({ id, expected }) => {
-
    expect(utils.isNodeId(id)).toEqual(expected);
-
  });
-

-
  test.each([
    { url: "https://app.radicle.xyz", expected: true },
    { url: "http://app.radicle.xyz", expected: true },
    { url: "http://app", expected: true },
@@ -138,12 +86,6 @@ describe("String Assertions", () => {

describe("Parse Functions", () => {
  test.each([
-
    { input: "https://twitter.com/cloudhead", expected: "cloudhead" },
-
    { input: "sebastinez", expected: "sebastinez" },
-
  ])("parseUsername", ({ input, expected }) => {
-
    expect(utils.parseUsername(input)).toEqual(expected);
-
  });
-
  test.each([
    {
      input: "rad:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
      expected: undefined,
modified tests/visual/desktop/cob.spec.ts
@@ -23,7 +23,7 @@ test("issues page", async ({ page }) => {

test("issue page", async ({ page }) => {
  const issues = [
-
    ["This title has markdown", "open"],
+
    ["This title has **markdown**", "open"],
    ["A closed issue", "closed"],
    ["A solved issue", "closed"],
  ];
modified tests/visual/desktop/landingPage.spec.ts
@@ -3,6 +3,10 @@ import sinon from "sinon";

test("pinned projects", async ({ page }) => {
  await page.addInitScript(() => {
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
+
    );
    sinon.useFakeTimers({
      now: new Date("November 24 2022 12:00:00").valueOf(),
      shouldClearNativeTimers: true,
@@ -16,6 +20,10 @@ test("pinned projects", async ({ page }) => {

test("load projects error", async ({ page }) => {
  await page.addInitScript(() => {
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
+
    );
    sinon.useFakeTimers({
      now: new Date("November 24 2022 12:00:00").valueOf(),
      shouldClearNativeTimers: true,
@@ -33,6 +41,12 @@ test("load projects error", async ({ page }) => {
});

test("response parse error", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
+
    );
+
  });
  await page.route("*/**/v1/projects*", route => {
    return route.fulfill({
      json: [{ name: 1337 }],
@@ -43,6 +57,12 @@ test("response parse error", async ({ page }) => {
});

test("response error", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    localStorage.setItem(
+
      "configuredPreferredSeeds",
+
      JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]),
+
    );
+
  });
  await page.route("*/**/v1/projects*", route => {
    return route.fulfill({
      status: 500,
modified tests/visual/desktop/markdown.spec.ts
@@ -111,33 +111,10 @@ test("footnotes", async ({ page }) => {
  await page.goto(`${markdownUrl}/tree/main/footnotes.md#footnotes`, {
    waitUntil: "networkidle",
  });
-
  await expect(
-
    page.locator(
-
      "text=This is an example footnote[1]. And some radicle[2] examples.[3]",
-
    ),
-
  ).toBeVisible();
-
  await expect(page.getByText("1. https://example.com ↩")).toBeVisible();
-
  await expect(page.getByText("2. https://radicle.xyz ↩")).toBeVisible();
-
  await expect(
-
    page.getByText(
-
      "3. A corporeal grounding, though one hardly to the exclusion of the cerebral as in the erroneous sense of a mind/body dichotomy, except insofar as what is most squarely in the cerebral would land here only on the periphery of our focus. ↩",
-
    ),
-
  ).toBeVisible();
  await expect(page).toHaveScreenshot({ fullPage: true });

  await page.getByText("Code").click();
-
  await expect(
-
    page.locator(
-
      "text=This is an example footnote[^1]. And some radicle[^2] examples.[^3]",
-
    ),
-
  ).toBeVisible();
-
  await expect(page.getByText("[^1]: https://example.com")).toBeVisible();
-
  await expect(page.getByText("[^2]: https://radicle.xyz")).toBeVisible();
-
  await expect(
-
    page.getByText(
-
      "[^3]: A corporeal grounding, though one hardly to the exclusion of the _cerebral_ as in the erroneous sense of a mind/body dichotomy, except insofar as what is most squarely in the cerebral would land here only on the periphery of our focus.",
-
    ),
-
  ).toBeVisible();
+
  await expect(page).toHaveScreenshot({ fullPage: true });
});

test("math", async ({ page }) => {
@@ -164,6 +141,6 @@ test("markdown in issues is not overflowing", async ({ page }) => {
  await page.goto(`${markdownUrl}/issues`, {
    waitUntil: "networkidle",
  });
-
  await page.getByRole("link", { name: "This title has markdown" }).click();
+
  await page.getByRole("link", { name: "This title has **markdown**" }).click();
  await expect(page).toHaveScreenshot({ fullPage: true });
});
modified tests/visual/desktop/project.spec.ts
@@ -122,6 +122,16 @@ test("response error", async ({ page }) => {
});

test("readme not found", async ({ page }) => {
+
  await page.route(
+
    ({ pathname }) =>
+
      pathname ===
+
      `http://127.0.0.1:8081/api/v1/projects/${sourceBrowsingRid}/readme/f591f9c3d842fdfb9e170e0f467189c6d9e950a2`,
+
    route => {
+
      return route.fulfill({
+
        status: 500,
+
      });
+
    },
+
  );
  await page.goto(`${markdownUrl}/tree`, {
    waitUntil: "networkidle",
  });
modified tests/visual/mobile/cob.spec.ts
@@ -23,7 +23,7 @@ test("issues page", async ({ page }) => {

test("issue page", async ({ page }) => {
  const issues = [
-
    ["This title has markdown", "open"],
+
    ["This title has **markdown**", "open"],
    ["A closed issue", "closed"],
    ["A solved issue", "closed"],
  ];
modified vite.config.ts
@@ -19,6 +19,7 @@ export default defineConfig({
      dynamicCompileOptions({ filename }) {
        if (
          path.basename(filename) === "Clipboard.svelte" ||
+
          path.basename(filename) === "ExternalLink.svelte" ||
          path.basename(filename) === "IconSmall.svelte"
        ) {
          return { customElement: true };